diff --git a/database/routes/simulationRoutes.js b/database/routes/simulationRoutes.js index 79d8d78..36ef0fe 100644 --- a/database/routes/simulationRoutes.js +++ b/database/routes/simulationRoutes.js @@ -1,9 +1,34 @@ 'use strict'; const express = require('express'); const { missionHelpers, playerHelpers, simulationHelpers, logToFile } = require('../mariadb'); +const { + normalizeMovement, + movementBudget, + stepToward, + enemyMoveTowardNearest, + battlemapActorKey, + pointFromLayout, + createBattlemapPositions, + normalizeWeaponRange, + attackPositioning, + visibilityBetween, + distance, + normalizeBattlemapLayout, +} = require('../../src/shared/battlemapEngine'); const router = express.Router(); +// Battlemap objective mini-game tuning. Loosened from the original +// 2 stations / 3-round core hold / 2 extraction progress / every-3-rounds +// reinforcements after simulation runs showed every squad going down without +// ever clearing a single mapped combat scene (fights ran 16-21 rounds, +// hitting the round cap on attrition alone). See docs/incident notes. +const STATIONS_REQUIRED = 1; +const GENE_CORE_ROUNDS_REQUIRED = 2; +const EXTRACTION_PROGRESS_REQUIRED = 1; +const REINFORCEMENT_INTERVAL_ROUNDS = 5; +const OBJECTIVE_CHECK_PENALTY = 0; // was a flat -10 on top of difficulty checkMod + // ─── Dice engine ───────────────────────────────────────────────────────────── function d(sides) { return Math.floor(Math.random() * sides) + 1; } function d100() { return d(100); } @@ -69,6 +94,7 @@ function buildPlayerState(dbPlayer) { maxWounds: maxWoundsValue, fate: fateValue, maxFate: maxFateValue, + movement: normalizeMovement(tab.movement || {}), weapons, conditions: [], fateUsed: false, @@ -133,6 +159,7 @@ function normalizeSimWeapon(weapon, gearText = '') { return { ...weapon, damage: defaultDamageForWeapon(weapon), + range: weapon.range || `${normalizeWeaponRange(weapon)}m`, isGrenade, clipSize: usesAmmo ? clip : 0, ammoRemaining: usesAmmo ? clip : null, @@ -154,6 +181,13 @@ function weaponIsGrenade(weapon) { return Boolean(weapon?.isGrenade) || /grenade/.test(String(weapon?.name || '').toLowerCase()); } +function attackGlyphForWeapon(weapon, mode = 'ranged') { + const name = String(weapon?.name || weapon || '').toLowerCase(); + if (/grenade|missile|rocket|bomb|blast/.test(name)) return 'explosive'; + if (mode === 'melee' || /sword|axe|knife|hammer|fist|claw|staff|chain/.test(name)) return 'melee'; + return 'ranged'; +} + function weaponIsRanged(weapon) { return weaponUsesAmmo(weapon) && !weaponIsGrenade(weapon) && !weaponIsMelee(weapon); } @@ -176,7 +210,7 @@ function parseRof(weapon) { } function chooseFireMode(weapon) { - if (!weaponUsesAmmo(weapon)) return { mode: 'melee', shots: 0 }; + if (!weaponUsesAmmo(weapon)) return { mode: 'melee', shots: 1 }; const rof = parseRof(weapon); const available = Math.max(0, Number(weapon.ammoRemaining || 0)); const options = [ @@ -200,28 +234,34 @@ function fireModeAttackModifier(mode) { } function hitsForFireMode(mode, degreesOfSuccess, shotsDeclared) { - if (!degreesOfSuccess || shotsDeclared <= 0) return 0; + if (!degreesOfSuccess) return 0; + if (mode === 'melee') return 1; + if (shotsDeclared <= 0) return 0; if (mode === 'full_auto') return Math.min(shotsDeclared, 1 + degreesOfSuccess); if (mode === 'semi_auto') return Math.min(shotsDeclared, 1 + Math.floor(degreesOfSuccess / 2)); return 1; } -function chooseEnemyAttackProfile(enemy) { +function chooseEnemyAttackProfile(enemy, distanceToTarget = 0) { const profile = enemy.combatProfile || 'balanced'; const ws = Number(enemy.ws || 45); const bs = Number(enemy.bs || 45); - if (profile === 'melee') return { mode: 'melee', stat: 'WS', target: ws }; - if (profile === 'ranged') return { mode: 'ranged', stat: 'BS', target: bs }; + const rangedRange = Number(enemy.range || 30) || 30; + if (distanceToTarget <= 2.5 && ws >= bs - 10) return { mode: 'melee', stat: 'WS', target: ws, range: 2 }; + if (profile === 'melee') return { mode: 'melee', stat: 'WS', target: ws, range: 2 }; + if (profile === 'ranged') return { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange }; if (profile === 'auto') { - return ws >= bs ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; + return ws >= bs && distanceToTarget <= 2.5 ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange }; } if (Math.abs(ws - bs) >= 10) { - return ws > bs ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; + return ws > bs && distanceToTarget <= 2.5 ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange }; } - return Math.random() < 0.5 ? { mode: 'melee', stat: 'WS', target: ws } : { mode: 'ranged', stat: 'BS', target: bs }; + return Math.random() < 0.5 && distanceToTarget <= 2.5 + ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } + : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange }; } -function chooseAttackWeapon(player, enemies = []) { +function chooseAttackWeapon(player, enemies = [], distanceToTarget = 0) { const weapons = Array.isArray(player.weapons) ? player.weapons : []; const profile = player.combatProfile || 'balanced'; const availableGrenades = weapons.filter(w => weaponIsGrenade(w) && weaponUsesAmmo(w) && Number(w.ammoRemaining || 0) > 0); @@ -233,7 +273,17 @@ function chooseAttackWeapon(player, enemies = []) { return availableGrenades.find(w => /krak/i.test(w.name || '')) || availableGrenades[0]; } const bestMelee = weapons.find(weaponIsMelee); - const bestRanged = weapons.find(w => weaponIsRanged(w) && Number(w.ammoRemaining || 0) > 0); + const rangedOptions = weapons.filter(w => weaponIsRanged(w) && Number(w.ammoRemaining || 0) > 0); + const bestRanged = rangedOptions + .slice() + .sort((a, b) => { + const aRange = normalizeWeaponRange(a); + const bRange = normalizeWeaponRange(b); + const aFit = Math.abs(aRange - distanceToTarget); + const bFit = Math.abs(bRange - distanceToTarget); + return aFit - bFit; + })[0]; + if (distanceToTarget <= 2.5 && bestMelee) return bestMelee; if (profile === 'melee' && bestMelee) return bestMelee; if (profile === 'ranged' && bestRanged) return bestRanged; if (profile === 'auto') { @@ -299,9 +349,9 @@ function conditionMod(p, type) { return m; } -function dealDamage(player, dmgFormula, armorPen) { +function dealDamage(player, dmgFormula, armorPen, extraAbsorption = 0) { const dmg = dX(dmgFormula); - const absorption = Math.max(0, 6 - (armorPen || 0)); + const absorption = Math.max(0, 6 - (armorPen || 0)) + Math.max(0, Number(extraAbsorption || 0)); const net = Math.max(0, dmg - absorption); player.wounds -= net; return { dmg, absorption, net }; @@ -387,8 +437,24 @@ function fearTest(player, fearRating, sceneTitle, rollFeed) { return r; } +function visibleTargetsForActor(battlemapContext, actorPos, candidates, side) { + if (!battlemapContext || !actorPos) return []; + return candidates + .map((entry) => { + const pos = battlemapContext.positions?.[battlemapActorKey(side, entry.name)]; + if (!pos) return null; + const visibility = visibilityBetween(battlemapContext, actorPos, pos); + if (!visibility.visible) return null; + return { entry, pos, visibility, dist: visibility.distance }; + }) + .filter(Boolean) + .sort((a, b) => a.dist - b.dist); +} + // ─── Combat round ──────────────────────────────────────────────────────────── -function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events, diff) { +function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, events, diff, options = {}) { + const battlemapContext = options.battlemapContext || null; + const objectiveActors = options.objectiveActors || new Set(); // Initiative const initiatives = [ ...players.map(p => ({ name: p.name, total: Math.floor(p.Ag / 10) + d10(), isNpc: false })), @@ -401,23 +467,56 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e if (actor.isNpc) { const enemy = enemies.find(e => e.name === actor.name); if (!enemy || enemy.wounds <= 0) continue; - const target = players.filter(isAlive)[Math.floor(Math.random() * players.filter(isAlive).length)]; + if (battlemapContext) { + scriptBattlemapEnemyAdvance(roundNum, battlemapContext, enemy, players, sceneTitle, events); + } + const livingPlayers = players.filter(isAlive); + const enemyPos = battlemapContext?.positions?.[battlemapActorKey('enemy', enemy.name)] || null; + const target = enemyPos && battlemapContext + ? visibleTargetsForActor(battlemapContext, enemyPos, livingPlayers, 'player')[0]?.entry + : livingPlayers[Math.floor(Math.random() * livingPlayers.length)]; if (!target) continue; - const enemyAttack = chooseEnemyAttackProfile(enemy); + const targetPos = battlemapContext?.positions?.[battlemapActorKey('player', target.name)] || null; + const targetDistance = enemyPos && targetPos ? distance(enemyPos, targetPos) : 0; + const enemyAttack = chooseEnemyAttackProfile(enemy, targetDistance); + const enemyAllyPositions = enemies + .filter(other => other !== enemy && other.wounds > 0) + .map(other => battlemapContext?.positions?.[battlemapActorKey('enemy', other.name)]) + .filter(Boolean); + const positioning = (battlemapContext && enemyPos && targetPos) + ? attackPositioning(battlemapContext, enemyPos, targetPos, { name: enemy.name, range: `${enemyAttack.range || 30}m`, pen: enemy.ap || 0 }, enemyAttack.mode, enemyAllyPositions) + : null; + if (positioning?.blocked || (enemyAttack.mode === 'melee' && positioning?.range?.band === 'melee_out_of_range')) { + events.push({ + type: 'battlemapAttack', + attacker: enemy.name, + target: target.name, + mode: enemyAttack.mode, + blocked: true, + glyph: attackGlyphForWeapon(enemy.name, enemyAttack.mode), + reasons: positioning?.reasons || ['No valid line'], + }); + continue; + } const attackRoll = d100(); - const hit = attackRoll <= enemyAttack.target; + const battlemapMod = positioning ? positioning.totalModifier : 0; + const enemyTarget = Math.max(5, Math.min(95, enemyAttack.target + battlemapMod)); + const hit = attackRoll <= enemyTarget; events.push({ type: 'enemyAttack', enemy: enemy.name, target: target.name, mode: enemyAttack.mode, + glyph: attackGlyphForWeapon(enemy.name, enemyAttack.mode), stat: enemyAttack.stat, roll: attackRoll, - tn: enemyAttack.target, + tn: enemyTarget, + distance: Number(targetDistance || 0).toFixed(1), + reasons: positioning?.reasons || [], hit, }); if (hit) { - const { net } = dealDamage(target, enemy.damage || '1d5', enemy.ap || 0); + const { net } = dealDamage(target, enemy.damage || '1d5', enemy.ap || 0, enemyAttack.mode === 'ranged' ? positioning?.coverAp || 0 : 0); if (net > 0) { if (net >= 5) addCondition(target, d(2) === 1 ? 'Pinned' : 'Stunned'); if (!isAlive(target)) { @@ -429,10 +528,21 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e } else { const player = players.find(p => p.name === actor.name); if (!player || !isAlive(player)) continue; + if (objectiveActors.has(player.name)) continue; if (player.conditions.includes('Stunned')) { removeCondition(player, 'Stunned'); continue; } + if (battlemapContext.hasAuthoredLayout) { + scriptBattlemapPlayerAdvance(roundNum, battlemapContext, player, sceneTitle, events); + } else { + scriptBattlemapPlayerAdvanceGeneric(roundNum, battlemapContext, player, enemies, sceneTitle, events); + } + const livingEnemies = enemies.filter(e => e.wounds > 0); + const playerPos = battlemapContext?.positions?.[battlemapActorKey('player', player.name)] || null; + const firstEnemy = playerPos && battlemapContext + ? visibleTargetsForActor(battlemapContext, playerPos, livingEnemies, 'enemy')[0]?.entry + : livingEnemies[0]; // 50% chance to do a check vs attack const diffCheckMod = (diff && diff.checkMod) || 0; if (checks.length > 0 && Math.random() < 0.45) { @@ -450,20 +560,44 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e dof: r.dof, }); } else { - const weapon = chooseAttackWeapon(player, enemies); + if (!firstEnemy) continue; + const targetPos = firstEnemy ? battlemapContext?.positions?.[battlemapActorKey('enemy', firstEnemy.name)] || null : null; + const targetDistance = playerPos && targetPos ? distance(playerPos, targetPos) : 0; + const weapon = chooseAttackWeapon(player, enemies, targetDistance); const fireMode = chooseFireMode(weapon); const ammo = spendAmmo(weapon, fireMode.shots); const statName = weaponIsMelee(weapon) ? 'WS' : 'BS'; const base = player[statName] || 50; const cMod = conditionMod(player, statName); const modeMod = fireModeAttackModifier(fireMode.mode); - const r = roll(base, cMod + modeMod); - const firstEnemy = enemies.find(e => e.wounds > 0); + const playerAllyPositions = players + .filter(other => other !== player && isAlive(other)) + .map(other => battlemapContext?.positions?.[battlemapActorKey('player', other.name)]) + .filter(Boolean); + const positioning = (battlemapContext && firstEnemy && playerPos && targetPos) + ? attackPositioning(battlemapContext, playerPos, targetPos, weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged', playerAllyPositions) + : null; + if (positioning?.blocked || positioning?.range?.band === 'out_of_range' || positioning?.range?.band === 'melee_out_of_range') { + player.failCount++; + events.push({ + type: 'battlemapAttack', + attacker: player.name, + target: firstEnemy?.name || 'enemy', + mode: weaponIsMelee(weapon) ? 'melee' : 'ranged', + weapon: weapon.name || 'weapon', + blocked: true, + glyph: attackGlyphForWeapon(weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged'), + reasons: positioning?.reasons || ['Target out of range'], + }); + continue; + } + const mapMod = positioning ? positioning.totalModifier : 0; + const r = roll(base, cMod + modeMod + mapMod); const hits = r.success ? hitsForFireMode(fireMode.mode, r.dos, fireMode.shots || 1) : 0; rollFeed.push({ player: player.name, checkName: `Attack (${weapon.name || 'weapon'})`, category: 'combat', - sceneName: sceneTitle, statName, base, modifier: cMod + modeMod, + sceneName: sceneTitle, statName, base, modifier: cMod + modeMod + mapMod, result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof, fireMode: fireMode.mode, fireModeModifier: modeMod, @@ -473,6 +607,10 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e specialAmmo: Boolean(weapon.specialAmmo), ammoSpent: ammo.spent, ammoRemaining: ammo.remaining, + battlemapReasons: positioning?.reasons || [], + targetName: firstEnemy?.name || null, + mode: weaponIsMelee(weapon) ? 'melee' : 'ranged', + distance: Number(targetDistance || 0).toFixed(1), }); if (ammo.spent > 0) { events.push({ @@ -486,14 +624,39 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e remaining: ammo.remaining, }); } + if (positioning) { + events.push({ + type: 'battlemapAttack', + attacker: player.name, + target: firstEnemy?.name || 'enemy', + mode: weaponIsMelee(weapon) ? 'melee' : 'ranged', + weapon: weapon.name || 'weapon', + glyph: attackGlyphForWeapon(weapon, weaponIsMelee(weapon) ? 'melee' : 'ranged'), + reasons: positioning.reasons, + }); + } if (r.success) { player.successCount++; if (firstEnemy) { - let dmg = 0; - for (let hit = 0; hit < Math.max(1, hits); hit++) { - dmg += dX(weapon.damage || '1d10+4'); + if (firstEnemy.horde) { + const tb = firstEnemy.tb || 4; + // Horde: each hit that inflicts any damage after TB kills 1 creature + let magnitudeLoss = 0; + for (let hit = 0; hit < Math.max(1, hits); hit++) { + const hitDmg = Math.max(0, dX(weapon.damage || '1d10+4') - (weaponIsMelee(weapon) ? 0 : (positioning?.coverAp || 0))); + if (hitDmg > tb) magnitudeLoss += 1; + } + firstEnemy.wounds -= magnitudeLoss; + if (magnitudeLoss > 0) { + events.push({ type: 'horde_damage', player: player.name, target: firstEnemy.name, creaturesKilled: magnitudeLoss, scene: sceneTitle }); + } + } else { + let dmg = 0; + for (let hit = 0; hit < Math.max(1, hits); hit++) { + dmg += Math.max(0, dX(weapon.damage || '1d10+4') - (weaponIsMelee(weapon) ? 0 : (positioning?.coverAp || 0))); + } + firstEnemy.wounds -= dmg; } - firstEnemy.wounds -= dmg; if (r.dos >= 4) { player.heroMoments.push(`Critical hit on ${firstEnemy.name} with ${weapon.name || 'weapon'} (${r.dos} DoS) in ${sceneTitle}`); events.push({ type: 'crit', player: player.name, target: firstEnemy.name, dos: r.dos }); @@ -509,6 +672,10 @@ function combatRound(roundNum, players, enemies, checks, sceneTitle, rollFeed, e player.shameList.push(`Exposed flank against ${firstEnemy?.name || 'enemy'} in ${sceneTitle}`); } } + if (battlemapContext && Math.random() < battlemapContext.hazardFatigueChance) { + addCondition(player, 'Fatigued'); + events.push({ type: 'hazard', player: player.name, scene: sceneTitle, round: roundNum, text: 'Hazard pressure from sludge, vents, or collapsed decking.' }); + } } if (player.conditions.includes('Pinned') && Math.random() < 0.4) removeCondition(player, 'Pinned'); } @@ -554,6 +721,9 @@ function enemiesFromScene(scene, sceneIndex, diff, enemyProfile = 'balanced') { if (raw.length > 0) { enemies = raw.map((e, ei) => ({ name: e.name || `Enemy ${ei+1}`, + horde: !!e.horde, + magnitude: e.magnitude || (e.horde ? scaleW(e.wounds || (baseWounds + ei * 2)) : null), + tb: e.tb || 4, wounds: scaleW(e.wounds || (baseWounds + ei * 2)), bs: scaleBs(e.bs || baseBs), ws: scaleBs(e.ws || (baseBs - 5)), @@ -565,7 +735,7 @@ function enemiesFromScene(scene, sceneIndex, diff, enemyProfile = 'balanced') { } else { enemies = [ { name: 'Tyranid Warrior', wounds: scaleW(baseWounds), bs: scaleBs(baseBs), ws: scaleBs(baseBs + 5), ap: baseAp, damage: baseDmg, agBonus: 4, combatProfile: enemyProfile }, - { name: 'Gaunt Swarm', wounds: scaleW(baseWounds - 4), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5, combatProfile: enemyProfile }, + { name: 'Gaunt Swarm', horde: true, magnitude: scaleW(baseWounds - 2), wounds: scaleW(baseWounds - 2), bs: scaleBs(baseBs - 5), ws: scaleBs(baseBs), ap: 0, damage: '1d5+2', agBonus: 5, combatProfile: enemyProfile === 'ranged' ? 'balanced' : 'melee' }, ]; } @@ -597,6 +767,363 @@ function titleForScene(scene, index) { return scene?.title || scene?.name || `Scene ${index + 1}`; } +function battlemapObjectiveDefaults(layout = {}, objectives = {}) { + const stations = Array.isArray(layout.stations) ? layout.stations : []; + const spawnPoints = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : []; + return { + stations: stations.map((station, index) => ({ + id: station.id || `station-${index + 1}`, + name: station.name || `Station ${index + 1}`, + active: Boolean(objectives?.stations?.find(item => item.id === station.id)?.active), + })), + geneCoreSecuredRounds: Math.max(0, Number(objectives?.geneCoreSecuredRounds || 0) || 0), + extractionProgress: Math.max(0, Number(objectives?.extractionProgress || 0) || 0), + carrier: String(objectives?.carrier || ''), + activeSpawnId: objectives?.activeSpawnId || spawnPoints[0]?.id || null, + }; +} + +function buildBattlemapContext(scene = {}) { + // Always resolve a real layout (the scene's authored one, or the default + // battlemap) so melee/ranged range-gating and movement limits are based on + // actual positions for every simulated combat scene, not skipped whenever + // nobody has opened the Battlemap tab and saved a layout for that scene. + const state = scene?.battlemapState; + // Only scenes where a GM actually authored/saved a battlemap (via the + // Battlemap tab) get the vault-stations/gene-core/extraction objective + // mini-game and reinforcement waves — those are specific to that authored + // map, not something every plain combat scene should be required to clear. + // Position, movement, and melee/ranged range-gating apply regardless. + const hasAuthoredLayout = Boolean(state?.layout && Number(state.layout.gridWidth) && Number(state.layout.gridHeight)); + const layout = normalizeBattlemapLayout(scene, state?.layout || {}); + const zones = Array.isArray(layout.zones) ? layout.zones : []; + const cover = Array.isArray(layout.cover) ? layout.cover : []; + const hazards = Array.isArray(layout.hazards) ? layout.hazards : []; + const spawnPoints = Array.isArray(layout.spawnPoints) ? layout.spawnPoints : []; + const objectives = battlemapObjectiveDefaults(layout, state?.objectives); + const objectiveZones = new Set( + zones.filter(zone => /objective|vault|maintenance/i.test(String(zone.tone || zone.id || ''))).map(zone => zone.id) + ); + return { + hasAuthoredLayout, + gridWidth: Number(layout.gridWidth), + gridHeight: Number(layout.gridHeight), + metersPerChecker: Number(layout.metersPerChecker || 10) || 10, + zones, + doors: Array.isArray(layout.doors) ? layout.doors : [], + cover, + hazards, + blockers: Array.isArray(layout.blockers) ? layout.blockers : [], + stations: Array.isArray(layout.stations) ? layout.stations : [], + spawnPoints, + objectives, + reinforcementInterval: hasAuthoredLayout && spawnPoints.length ? REINFORCEMENT_INTERVAL_ROUNDS : 0, + reinforcementWaves: 0, + spawnHistory: objectives.activeSpawnId ? [objectives.activeSpawnId] : [], + objectiveActions: [], + objectiveZones, + playerRangedAttackMod: cover.length >= 8 ? -5 : 0, + enemyRangedAttackMod: cover.length >= 8 ? -10 : -5, + playerMeleeAttackMod: objectiveZones.size ? 5 : 0, + hazardFatigueChance: hazards.length ? 0.18 : 0, + positions: {}, + }; +} + +function nextBattlemapSpawn(context) { + if (!context?.spawnPoints?.length) return null; + const currentId = context.objectives.activeSpawnId; + const currentIndex = Math.max(0, context.spawnPoints.findIndex(point => point.id === currentId)); + const point = context.spawnPoints[currentIndex] || context.spawnPoints[0]; + const next = context.spawnPoints[(currentIndex + 1) % context.spawnPoints.length]; + context.objectives.activeSpawnId = next?.id || point.id; + if (point?.id) context.spawnHistory.push(point.id); + return point; +} + +function battlemapObjectiveActor(players) { + const alive = players.filter(isAlive); + if (!alive.length) return null; + return alive.reduce((best, player) => { + const bestScore = (best.Int || 0) + (best.Per || 0) + (best.Wp || 0); + const nextScore = (player.Int || 0) + (player.Per || 0) + (player.Wp || 0); + return nextScore > bestScore ? player : best; + }); +} + +function moveBattlemapActor(context, key, targetPoint, maxDistance, sceneEvents, roundNum, sceneTitle, actorName) { + if (!context?.positions?.[key] || !targetPoint) return null; + const start = context.positions[key]; + const occupied = Object.entries(context.positions) + .filter(([otherKey]) => otherKey !== key) + .map(([, point]) => point); + const moved = stepToward(start, targetPoint, maxDistance, { + width: context.gridWidth || 40, + height: context.gridHeight || 30, + }, { + layout: context, + occupied, + }); + context.positions[key] = { x: moved.x, y: moved.y }; + sceneEvents.push({ + type: 'battlemapMove', + round: roundNum, + actor: actorName, + side: 'player', + player: actorName, + scene: sceneTitle, + x: moved.x, + y: moved.y, + travelled: Number(moved.travelled || 0).toFixed(1), + }); + const remaining = Math.sqrt(Math.pow(targetPoint.x - moved.x, 2) + Math.pow(targetPoint.y - moved.y, 2)); + return { ...moved, remaining }; +} + +function playerBattlemapGoal(context) { + if (!context) return null; + const activeStations = context.objectives.stations.filter(station => station.active).length; + const inactive = context.objectives.stations.filter(station => !station.active); + if (inactive.length > 0 && activeStations < STATIONS_REQUIRED) { + return pointFromLayout({ stations: context.stations }, inactive[0].id, inactive[0].id); + } + if (context.objectives.geneCoreSecuredRounds < GENE_CORE_ROUNDS_REQUIRED) { + return pointFromLayout({ zones: context.zones }, 'vault', 'core'); + } + if (context.objectives.extractionProgress < EXTRACTION_PROGRESS_REQUIRED) { + return pointFromLayout({ zones: context.zones }, 'extraction', 'extraction'); + } + return pointFromLayout({ zones: context.zones }, 'vault', 'vault'); +} + +function scriptBattlemapPlayerAdvance(roundNum, context, player, sceneTitle, sceneEvents) { + if (!context || !player || !isAlive(player)) return; + if (player.combatProfile === 'melee') { + const activeEnemies = (context.enemyRoster || []) + .filter(enemy => enemy.wounds > 0) + .map(enemy => { + const point = context.positions?.[battlemapActorKey('enemy', enemy.name)]; + return point ? { name: enemy.name, x: point.x, y: point.y } : null; + }) + .filter(Boolean); + if (activeEnemies.length) { + scriptBattlemapPlayerAdvanceGeneric(roundNum, context, player, context.enemyRoster || [], sceneTitle, sceneEvents); + return; + } + } + const key = battlemapActorKey('player', player.name); + const targetPoint = playerBattlemapGoal(context); + if (!context.positions[key] || !targetPoint) return; + moveBattlemapActor(context, key, targetPoint, movementBudget(player.movement, 'full'), sceneEvents, roundNum, sceneTitle, player.name); +} + +// Used for scenes without an authored battlemap (no stations/vault/extraction +// objectives to advance toward): players close on the nearest living enemy +// instead, the same way scriptBattlemapEnemyAdvance moves enemies toward the +// nearest player. +function scriptBattlemapPlayerAdvanceGeneric(roundNum, context, player, enemies, sceneTitle, sceneEvents) { + if (!context || !player || !isAlive(player)) return; + const key = battlemapActorKey('player', player.name); + const playerPos = context.positions[key]; + if (!playerPos) return; + const enemyTokens = enemies + .filter(e => e.wounds > 0) + .map(enemy => { + const position = context.positions[battlemapActorKey('enemy', enemy.name)]; + return position ? { name: enemy.name, x: position.x, y: position.y } : null; + }) + .filter(Boolean); + if (!enemyTokens.length) return; + const scripted = enemyMoveTowardNearest(playerPos, enemyTokens, movementBudget(player.movement, 'full'), { + width: context.gridWidth || 40, + height: context.gridHeight || 30, + }, { + layout: context, + occupied: Object.entries(context.positions) + .filter(([otherKey]) => otherKey !== key) + .map(([, point]) => point), + }); + if (!scripted) return; + context.positions[key] = { x: scripted.x, y: scripted.y }; + sceneEvents.push({ + type: 'battlemapMove', + round: roundNum, + actor: player.name, + side: 'player', + player: player.name, + target: scripted.target.name, + scene: sceneTitle, + x: scripted.x, + y: scripted.y, + travelled: Number(scripted.travelled || 0).toFixed(1), + }); +} + +function scriptBattlemapEnemyAdvance(roundNum, context, enemy, players, sceneTitle, sceneEvents) { + if (!context || !enemy || enemy.wounds <= 0) return; + const key = battlemapActorKey('enemy', enemy.name); + const enemyPos = context.positions[key]; + if (!enemyPos) return; + const playerTokens = players + .filter(isAlive) + .map(player => { + const position = context.positions[battlemapActorKey('player', player.name)]; + return position ? { name: player.name, x: position.x, y: position.y } : null; + }) + .filter(Boolean); + const moveDistance = enemy.combatProfile === 'melee' || enemy.horde ? 2 : 1; + const scripted = enemyMoveTowardNearest(enemyPos, playerTokens, moveDistance, { + width: context.gridWidth || 40, + height: context.gridHeight || 30, + }, { + layout: context, + occupied: Object.entries(context.positions) + .filter(([otherKey]) => otherKey !== key) + .map(([, point]) => point), + }); + const charged = enemyMoveTowardNearest(enemyPos, playerTokens, moveDistance, { + width: context.gridWidth || 40, + height: context.gridHeight || 30, + }, { + layout: context, + occupied: Object.entries(context.positions) + .filter(([otherKey]) => otherKey !== key) + .map(([, point]) => point), + }); + const move = charged || scripted; + if (!move) return; + context.positions[key] = { x: move.x, y: move.y }; + sceneEvents.push({ + type: 'battlemapMove', + round: roundNum, + actor: enemy.name, + side: 'enemy', + target: move.target.name, + scene: sceneTitle, + x: move.x, + y: move.y, + travelled: Number(move.travelled || 0).toFixed(1), + }); +} + +function runBattlemapObjectiveRound(roundNum, context, players, sceneTitle, rollFeed, sceneEvents, diff) { + if (!context) return new Set(); + const occupied = new Set(); + const actor = battlemapObjectiveActor(players); + if (!actor) return occupied; + + const activeStations = context.objectives.stations.filter(station => station.active).length; + const inactive = context.objectives.stations.filter(station => !station.active); + if (inactive.length > 0 && activeStations < STATIONS_REQUIRED) { + const station = inactive[0]; + const stationPoint = pointFromLayout({ stations: context.stations }, station.id, station.id); + const moved = moveBattlemapActor(context, `player:${actor.name}`, stationPoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name); + occupied.add(actor.name); + if (moved?.remaining > 1.5) return occupied; + const check = performCheck(actor, `Stabilize ${station.name}`, 'Int', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat'); + sceneEvents.push({ + type: 'battlemapObjective', + objective: station.name, + player: actor.name, + action: 'stabilize', + roll: check.result, + tn: check.effective, + success: check.success, + dos: check.dos, + dof: check.dof, + round: roundNum, + }); + context.objectiveActions.push({ round: roundNum, player: actor.name, objective: station.name, success: check.success }); + if (check.success) station.active = true; + return occupied; + } + + if (context.objectives.geneCoreSecuredRounds < GENE_CORE_ROUNDS_REQUIRED) { + const corePoint = pointFromLayout({ zones: context.zones }, 'vault', 'core'); + const moved = moveBattlemapActor(context, `player:${actor.name}`, corePoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name); + occupied.add(actor.name); + if (moved?.remaining > 1.5) return occupied; + const check = performCheck(actor, 'Secure gene-core', 'Int', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat'); + sceneEvents.push({ + type: 'battlemapObjective', + objective: 'Gene-Core', + player: actor.name, + action: 'secure-core', + roll: check.result, + tn: check.effective, + success: check.success, + dos: check.dos, + dof: check.dof, + round: roundNum, + }); + context.objectiveActions.push({ round: roundNum, player: actor.name, objective: 'Gene-Core', success: check.success }); + if (check.success) { + context.objectives.geneCoreSecuredRounds += 1; + context.objectives.carrier = actor.name; + } + return occupied; + } + + if (context.objectives.extractionProgress < EXTRACTION_PROGRESS_REQUIRED) { + const extractionPoint = pointFromLayout({ zones: context.zones }, 'extraction', 'extraction'); + const moved = moveBattlemapActor(context, `player:${actor.name}`, extractionPoint, actor.movement?.full || 6, sceneEvents, roundNum, sceneTitle, actor.name); + occupied.add(actor.name); + if (moved?.remaining > 1.5) return occupied; + const check = performCheck(actor, 'Withdraw gene-core to extraction', 'Ag', (diff?.checkMod || 0) - OBJECTIVE_CHECK_PENALTY, rollFeed, sceneTitle, 'combat'); + sceneEvents.push({ + type: 'battlemapObjective', + objective: 'Extraction', + player: actor.name, + action: 'extract-core', + roll: check.result, + tn: check.effective, + success: check.success, + dos: check.dos, + dof: check.dof, + round: roundNum, + }); + context.objectiveActions.push({ round: roundNum, player: actor.name, objective: 'Extraction', success: check.success }); + if (check.success) context.objectives.extractionProgress += check.dos >= 2 ? 2 : 1; + } + + return occupied; +} + +function spawnBattlemapReinforcement(roundNum, context, enemies, sceneIndex, diff, sceneEvents) { + if (!context?.reinforcementInterval || roundNum % context.reinforcementInterval !== 0) return; + const point = nextBattlemapSpawn(context); + if (!point) return; + const d = diff || DIFFICULTY[2]; + context.reinforcementWaves += 1; + const isElite = /vault|breach/i.test(String(point.name || '')); + const reinforcement = { + name: isElite ? `Corrupted Marine ${context.reinforcementWaves}` : `Reinforcement Horde ${context.reinforcementWaves}`, + horde: /maintenance|service/i.test(String(point.name || '')), + magnitude: /maintenance|service/i.test(String(point.name || '')) ? Math.max(8, Math.round(10 * d.woundsMult)) : null, + tb: isElite ? 6 : 4, + wounds: Math.max(4, Math.round((isElite ? 16 : 8) * d.woundsMult)), + bs: Math.min(85, 40 + d.bsMod + (isElite ? 10 : 0)), + ws: Math.min(85, 45 + d.bsMod + (isElite ? 10 : 0)), + ap: isElite ? 4 : 1, + damage: isElite ? '1d10+6' : '1d5+3', + agBonus: isElite ? 5 : 4, + combatProfile: isElite ? 'melee' : 'melee', + reinforcement: true, + }; + enemies.push(reinforcement); + if (context?.positions) { + const spawnPoint = pointFromLayout({ spawnPoints: context.spawnPoints }, point.id, point.id); + context.positions[`enemy:${reinforcement.name}`] = { x: spawnPoint.x, y: spawnPoint.y }; + } + sceneEvents.push({ + type: 'reinforcement', + round: roundNum, + spawn: point.name, + enemy: reinforcement.name, + sceneIndex, + }); +} + function compactStoryText(value, fallback = '') { let text = ''; if (Array.isArray(value)) text = value.map(v => compactStoryText(v)).filter(Boolean).join(' '); @@ -711,21 +1238,83 @@ function runSimulation(mission, playerStates, diff, options = {}) { const enemies = enemiesFromScene(scene, si, d, enemyProfile); const checks = (scene.checks || []).map(c => ({ name: c.name, stat: statFromCheck(c), modifier: c.modifier || 0 })); const isBoss = si >= scenes.length - 2; - const maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds); + const battlemapContext = buildBattlemapContext(scene); + battlemapContext.positions = createBattlemapPositions({ + zones: battlemapContext.zones, + stations: battlemapContext.stations, + spawnPoints: battlemapContext.spawnPoints, + }, playerStates, enemies); + battlemapContext.enemyRoster = enemies; + // battlemapContext.positions gets mutated in place every round as actors + // move, so it holds end-of-combat positions by the time the scene report + // is built below. Snapshot the true starting positions here — the replay + // UI seeds from this, not the final positions, or every token would + // render at its last position instead of where the fight actually began. + const startingPositions = Object.fromEntries( + Object.entries(battlemapContext.positions).map(([key, pos]) => [key, { ...pos }]) + ); + const maxRounds = (isBoss ? d.maxRounds + 1 : d.maxRounds) + (battlemapContext.hasAuthoredLayout ? 4 : 0); + const objectivesMetNow = () => !battlemapContext.hasAuthoredLayout || ( + battlemapContext.objectives.stations.filter(station => station.active).length >= STATIONS_REQUIRED + && battlemapContext.objectives.geneCoreSecuredRounds >= GENE_CORE_ROUNDS_REQUIRED + && battlemapContext.objectives.extractionProgress >= EXTRACTION_PROGRESS_REQUIRED + ); let round = 1; - while (round <= maxRounds && enemies.some(e => e.wounds > 0) && playerStates.some(isAlive)) { + while ( + round <= maxRounds + && playerStates.some(isAlive) + && (enemies.some(e => e.wounds > 0) || !objectivesMetNow()) + ) { totalRounds++; - combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents, d); + const objectiveActors = battlemapContext.hasAuthoredLayout + ? runBattlemapObjectiveRound(round, battlemapContext, playerStates, title, rollFeed, sceneEvents, d) + : new Set(); + spawnBattlemapReinforcement(round, battlemapContext, enemies, si, d, sceneEvents); + combatRound(round, playerStates, enemies, checks, title, rollFeed, sceneEvents, d, { + battlemapContext, + objectiveActors, + }); round++; } - const cleared = enemies.every(e => e.wounds <= 0); + const objectivesMet = objectivesMetNow(); + const cleared = enemies.every(e => e.wounds <= 0) && objectivesMet; if (cleared) { missionXp += 200; sceneSuccesses += Math.ceil(playerStates.filter(isAlive).length * 1.5); } else { missionXp += 75; } storyHooks.push(combatStoryHook({ scene, sceneIndex: si, scenes, mission, cleared })); - sceneResults.push({ title, type, cleared, enemyProfile, enemiesRemaining: enemies.filter(e=>e.wounds>0).length, events: sceneEvents }); + sceneResults.push({ + title, + type, + cleared, + enemyProfile, + enemiesRemaining: enemies.filter(e=>e.wounds>0).length, + battlemap: { + active: battlemapContext.hasAuthoredLayout, + stationsOnline: battlemapContext.objectives.stations.filter(station => station.active).length, + geneCoreSecuredRounds: battlemapContext.objectives.geneCoreSecuredRounds, + extractionProgress: battlemapContext.objectives.extractionProgress, + carrier: battlemapContext.objectives.carrier, + reinforcementWaves: battlemapContext.reinforcementWaves, + activeSpawnId: battlemapContext.objectives.activeSpawnId, + objectiveActions: battlemapContext.objectiveActions, + positions: battlemapContext.positions, + startingPositions, + objectivesMet, + // Map shape for the frontend's live replay view — every combat + // scene now has real positions (see buildBattlemapContext), so + // this is populated regardless of whether a GM authored a layout. + gridWidth: battlemapContext.gridWidth, + gridHeight: battlemapContext.gridHeight, + zones: battlemapContext.zones, + doors: battlemapContext.doors, + cover: battlemapContext.cover, + blockers: battlemapContext.blockers, + hazards: battlemapContext.hazards, + }, + events: sceneEvents, + }); } else { // Skill scene — difficulty applies check modifier const assignments = assignChecks(scene, playerStates); @@ -855,6 +1444,25 @@ function runSimulation(mission, playerStates, diff, options = {}) { if (unclearedScenes.length > 0) { findings.push(`Combat: ${unclearedScenes.length}/${Math.max(1, combatScenes.length)} combat scene${combatScenes.length === 1 ? '' : 's'} ended uncleared. Add retreat conditions, stagger enemy arrivals, or let a successful objective roll bypass the remaining enemies.`); } + const battlemapScenes = combatScenes.filter(s => s.battlemap?.active); + if (battlemapScenes.length > 0) { + const failedObjectives = battlemapScenes.filter(s => !s.battlemap.objectivesMet); + const totalWaves = battlemapScenes.reduce((sum, scene) => sum + Number(scene.battlemap.reinforcementWaves || 0), 0); + const coreProgress = battlemapScenes.map(scene => Number(scene.battlemap.geneCoreSecuredRounds || 0)); + if (failedObjectives.length > 0) { + findings.push(`Battlemap: ${failedObjectives.length}/${battlemapScenes.length} mapped combat scene${battlemapScenes.length === 1 ? '' : 's'} failed the station/core objective. Ease objective timing, give safer console access, or reduce reinforcement tempo.`); + } + if (totalWaves > battlemapScenes.length * 2) { + findings.push(`Battlemap: reinforcements hit ${totalWaves} times across ${battlemapScenes.length} mapped scene${battlemapScenes.length === 1 ? '' : 's'}. Consider 3-round spacing, a telegraphed spawn, or one spawn lane locked by a successful Tech-Use action.`); + } + if (coreProgress.length > 0 && Math.max(...coreProgress) < 3) { + findings.push('Battlemap: the gene-core was never held for the full 3 rounds. Add more vault cover, reduce elite contest pressure, or allow one round of progress to persist after a failed hold check.'); + } + const extractionProgress = battlemapScenes.map(scene => Number(scene.battlemap.extractionProgress || 0)); + if (extractionProgress.length > 0 && Math.max(...extractionProgress) < 2) { + findings.push('Battlemap: the carrier never completed the withdrawal to extraction. Shorten the retreat path, reduce rear-spawn pressure, or give the carrier more supporting cover.'); + } + } const skillScenes = sceneResults.filter(s => s.total !== undefined); const partialSkillScenes = skillScenes.filter(s => s.outcome !== 'success'); @@ -1031,4 +1639,13 @@ router.delete('/:id', async (req, res) => { } }); +router.__testables = { + runSimulation, + buildBattlemapContext, + buildPlayerState, + combatRound, + chooseEnemyAttackProfile, + DIFFICULTY, +}; + module.exports = router; diff --git a/src/components/SimulationTab.jsx b/src/components/SimulationTab.jsx index d873494..29f9db1 100644 --- a/src/components/SimulationTab.jsx +++ b/src/components/SimulationTab.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import axios from 'axios'; // ─── Difficulty config ──────────────────────────────────────────────────────── @@ -38,10 +38,507 @@ function resultBadge(result) { return PARTIAL FAILURE; } +function EventLine({ ev }) { + return ( +