feat: expand Simulation tab with combat simulation and replay

- src/components/SimulationTab.jsx: +544 lines, combat simulation
  UI with horde combat, player actions, and replay functionality
- database/routes/simulationRoutes.js: +681 lines, backend routes
  for simulation state management and combat resolution
- src/tests/simulationTabReplay.test.js: replay functionality tests
- src/tests/hordeCombatFourPlayersFiveHordes.test.js: multi-player
  horde combat scenario tests
- src/tests/hordeCombatPlayerMainWeapon.test.js: player weapon
  damage calculation tests
This commit is contained in:
2026-07-15 19:36:38 +02:00
parent 2a6224ee6f
commit b2dd5a44d3
5 changed files with 1525 additions and 49 deletions

View File

@@ -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;