From b2dd5a44d34a25c6bf35a561e507992437628968 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 19:36:38 +0200 Subject: [PATCH] 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 --- database/routes/simulationRoutes.js | 681 +++++++++++++++++- src/components/SimulationTab.jsx | 544 +++++++++++++- .../hordeCombatFourPlayersFiveHordes.test.js | 132 ++++ src/tests/hordeCombatPlayerMainWeapon.test.js | 103 +++ src/tests/simulationTabReplay.test.js | 114 +++ 5 files changed, 1525 insertions(+), 49 deletions(-) create mode 100644 src/tests/hordeCombatFourPlayersFiveHordes.test.js create mode 100644 src/tests/hordeCombatPlayerMainWeapon.test.js create mode 100644 src/tests/simulationTabReplay.test.js 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 ( +
+ {ev.type === 'crit' && ⚡ {ev.player} — Righteous Fury on {ev.target} ({ev.dos} DoS)} + {ev.type === 'initiative' && Initiative round {ev.round}: {(ev.order || []).join(' > ')}} + {ev.type === 'kill' && ☠ {ev.player} slew {ev.target}} + {ev.type === 'down' && 💀 {ev.player} went down (round {ev.round})} + {ev.type === 'checkRoll' && {ev.player} — {ev.check} [{ev.stat}] {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}} + {ev.type === 'heroCheck' && ★ {ev.player} — {ev.check} ({ev.dos} DoS)} + {ev.type === 'complication'&& ⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}} + {ev.type === 'wound' && 🩸 {ev.player} took {ev.net} wounds from complication} + {ev.type === 'ammo' && {ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)} + {ev.type === 'enemyAttack' && {ev.enemy} {ev.mode} vs {ev.target}{ev.distance != null ? ` (${ev.distance}m)` : ''}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}} + {ev.type === 'battlemapMove' && {ev.actor || ev.player} advanced{ev.target ? ` toward ${ev.target}` : ''} to {ev.x},{ev.y} ({ev.travelled}m)} + {ev.type === 'battlemapAttack' && {ev.attacker} vs {ev.target}{ev.weapon ? ` (${ev.weapon})` : ''}: {(ev.reasons || []).join(', ')}} + {ev.type === 'battlemapObjective' && {ev.player} {ev.action === 'secure-core' ? 'secured the gene-core' : ev.action === 'extract-core' ? 'withdrew the gene-core' : `worked ${ev.objective}`}: {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}} + {ev.type === 'reinforcement' && ↺ Reinforcement from {ev.spawn}: {ev.enemy}} + {ev.type === 'horde_damage' && ☠ {ev.player} cut down {ev.creaturesKilled} from {ev.target}} + {ev.type === 'hazard' && ☣ {ev.player}: {ev.text}} +
+ ); +} + +function fillForZoneTone(tone) { + if (tone === 'spawn') return '#7c2d12'; + if (tone === 'corridor') return '#1d4ed8'; + if (tone === 'objective') return '#991b1b'; + if (tone === 'hazard') return '#0f766e'; + if (tone === 'vault') return '#4c1d95'; + return '#334155'; +} + +function lightingOverlay(lighting) { + if (lighting === 'dark') return { fill: 'url(#sim-darkness)', opacity: 0.5 }; + if (lighting === 'dim') return { fill: 'url(#sim-dimness)', opacity: 0.35 }; + if (lighting === 'glow') return { fill: 'url(#sim-glow)', opacity: 0.32 }; + return null; +} + +function elevationStroke(elevation) { + if (Number(elevation) > 0) return '#facc15'; + if (Number(elevation) < 0) return '#22d3ee'; + return 'rgba(226,232,240,0.5)'; +} + +function elevationBadge(elevation) { + if (Number(elevation) > 0) return `▲${Number(elevation)}`; + if (Number(elevation) < 0) return `▼${Math.abs(Number(elevation))}`; + return ''; +} + +function coverFill(kind) { + return kind === 'heavy' ? '#b45309' : '#cbd5e1'; +} + +function coverPatternId(kind) { + return kind === 'heavy' ? 'sim-cover-heavy' : 'sim-cover-light'; +} + +function hazardPatternId(kind) { + if (kind === 'vent') return 'sim-hazard-vent'; + if (kind === 'agility') return 'sim-hazard-agility'; + return 'sim-hazard-default'; +} + +function findActorKeyByName(name, positions, preferredSide = '') { + const allKeys = Object.keys(positions || {}); + const preferred = preferredSide ? `${preferredSide}:${name}` : ''; + if (preferred && positions[preferred]) return preferred; + return allKeys.find((key) => key.slice(key.indexOf(':') + 1) === name) || null; +} + +function replayStateAtIndex(events, index, initialPositions = {}) { + const positions = Object.entries(initialPositions || {}).reduce((acc, [key, pos]) => { + if (pos && Number.isFinite(Number(pos.x)) && Number.isFinite(Number(pos.y))) { + acc[key] = { x: Number(pos.x), y: Number(pos.y) }; + } + return acc; + }, {}); + const eliminated = {}; + const recentAttacks = []; + + for (let i = 0; i <= index && i < events.length; i += 1) { + const ev = events[i]; + if (ev.type === 'battlemapMove') { + const name = ev.actor || ev.player; + if (!name) continue; + const key = `${ev.side === 'enemy' ? 'enemy' : 'player'}:${name}`; + positions[key] = { x: Number(ev.x), y: Number(ev.y) }; + continue; + } + if (ev.type === 'battlemapAttack') { + if (ev.blocked) continue; + const attackerKey = findActorKeyByName(ev.attacker, positions); + recentAttacks.push({ + mode: ev.mode || 'ranged', + attackerKey, + targetKey: findActorKeyByName(ev.target, positions), + attacker: ev.attacker, + target: ev.target, + attackerSide: attackerKey?.startsWith('enemy:') ? 'enemy' : 'player', + glyph: ev.glyph || (ev.mode === 'melee' ? 'melee' : 'ranged'), + step: i, + }); + continue; + } + if (ev.type === 'enemyAttack') { + recentAttacks.push({ + mode: ev.mode || 'ranged', + attackerKey: findActorKeyByName(ev.enemy, positions, 'enemy'), + targetKey: findActorKeyByName(ev.target, positions, 'player'), + attacker: ev.enemy, + target: ev.target, + attackerSide: 'enemy', + glyph: ev.glyph || (ev.mode === 'melee' ? 'melee' : 'ranged'), + step: i, + }); + continue; + } + if (ev.type === 'kill') { + const key = findActorKeyByName(ev.target, positions, 'enemy'); + if (key && positions[key]) { + eliminated[key] = { ...positions[key], name: ev.target, kind: 'kill' }; + delete positions[key]; + } + continue; + } + if (ev.type === 'down') { + const key = findActorKeyByName(ev.player, positions, 'player'); + if (key && positions[key]) { + eliminated[key] = { ...positions[key], name: ev.player, kind: 'down' }; + delete positions[key]; + } + } + } + + const activeAttacks = recentAttacks + .filter((attack) => index - attack.step <= 2) + .map((attack) => ({ + ...attack, + attackerPos: attack.attackerKey ? positions[attack.attackerKey] || eliminated?.[attack.attackerKey] : null, + targetPos: attack.targetKey ? positions[attack.targetKey] || eliminated?.[attack.targetKey] : null, + age: index - attack.step, + })) + .filter((attack) => attack.attackerPos && attack.targetPos); + + return { positions, eliminated, activeAttacks }; +} + +function ReplayMap({ battlemap, positions, eliminated, activeAttacks }) { + const width = Number(battlemap?.gridWidth) || 40; + const height = Number(battlemap?.gridHeight) || 30; + const zones = Array.isArray(battlemap?.zones) ? battlemap.zones : []; + const doors = Array.isArray(battlemap?.doors) ? battlemap.doors : []; + const cover = Array.isArray(battlemap?.cover) ? battlemap.cover : []; + const blockers = Array.isArray(battlemap?.blockers) ? battlemap.blockers : []; + const hazards = Array.isArray(battlemap?.hazards) ? battlemap.hazards : []; + const tokens = Object.entries(positions); + const eliminatedTokens = Object.entries(eliminated || {}); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: width + 1 }, (_, index) => ( + + ))} + {Array.from({ length: height + 1 }, (_, index) => ( + + ))} + {zones.map(zone => ( + + + {lightingOverlay(zone.lighting) && ( + + )} + {elevationBadge(zone.elevation) && ( + {elevationBadge(zone.elevation)} + )} + + ))} + {cover.map(item => ( + + + + + ))} + {doors.map((door) => ( + + + + ))} + {(battlemap?.hazards || []).map(item => ( + + + + + ))} + {blockers.map(item => ( + + ))} + {hazards.map(item => ( + + + + + ))} + {(activeAttacks || []).map((attack, idx) => { + const fade = attack.age === 0 ? 1 : attack.age === 1 ? 0.6 : 0.35; + const isEnemy = attack.attackerSide === 'enemy'; + const meleeStroke = isEnemy ? '#fb7185' : '#38bdf8'; + const rangedStroke = isEnemy ? '#f472b6' : '#60a5fa'; + const rangedFill = isEnemy ? '#f9a8d4' : '#93c5fd'; + const midX = (attack.attackerPos.x + attack.targetPos.x) / 2; + const midY = (attack.attackerPos.y + attack.targetPos.y) / 2; + return ( + + {(attack.glyph || attack.mode) === 'explosive' ? ( + <> + + + + + ) : attack.mode === 'melee' ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ); + })} + {tokens.map(([key, pos]) => { + const isEnemy = key.startsWith('enemy:'); + const name = key.slice(key.indexOf(':') + 1); + return ( + + + {name} + + ); + })} + {eliminatedTokens.map(([key, pos]) => ( + + + + + ))} + + ); +} + +const REPLAY_SPEEDS = [ + { label: 'Slow', ms: 700 }, + { label: 'Normal', ms: 350 }, + { label: 'Fast', ms: 120 }, +]; + +const REPLAY_VIEW_MODES = [ + { id: 'clean', label: 'Clean' }, + { id: 'tactical', label: 'Tactical' }, +]; + +function SceneReplay({ scene }) { + const events = useMemo(() => scene.events || [], [scene]); + // startingPositions is the pre-round-1 snapshot; battlemap.positions is the + // end-of-combat state (kept for older saved simulations run before this + // field existed) — starting positions is what the replay must seed from, + // or every token would render frozen at its final position until (if ever) + // its own first move event corrects it. + const initialPositions = useMemo( + () => scene.battlemap?.startingPositions || scene.battlemap?.positions || {}, + [scene] + ); + const [index, setIndex] = useState(-1); + const [playing, setPlaying] = useState(false); + const [speedMs, setSpeedMs] = useState(350); + const [viewMode, setViewMode] = useState('tactical'); + const logRef = useRef(null); + + useEffect(() => { + if (!playing) return undefined; + if (index >= events.length - 1) { setPlaying(false); return undefined; } + const timer = setTimeout(() => setIndex(i => Math.min(events.length - 1, i + 1)), speedMs); + return () => clearTimeout(timer); + }, [playing, index, events.length, speedMs]); + + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [index]); + + const replayState = useMemo(() => replayStateAtIndex(events, index, initialPositions), [events, index, initialPositions]); + const visibleEvents = events.slice(0, index + 1); + const atEnd = events.length > 0 && index >= events.length - 1; + + const handlePlayClick = () => { + if (playing) { setPlaying(false); return; } + if (atEnd) setIndex(-1); + setPlaying(true); + }; + + return ( +
+
+
+ Playback + + + +
+ +
+ View + {REPLAY_VIEW_MODES.map((mode) => ( + + ))} +
+
+
+
Progress
+
{Math.max(0, index + 1)}/{events.length}
+
+
+
+
+
+
Scene Replay
+
+ {viewMode === 'clean' + ? 'Clean view focuses on token movement, attacks, and removals.' + : 'Tactical view adds cover, elevation, lighting, hazards, and attack trails.'} +
+
+
+ {playing ? 'Playback running' : atEnd ? 'Replay finished' : index < 0 ? 'Ready to start' : 'Paused'} +
+
+
+
+
+
Battle Map
+
{Object.keys(replayState.positions || {}).length} active tokens
+
+ ({ + ...zone, + lighting: 'normal', + elevation: 0, + })) + : [], + } + : scene.battlemap + } + positions={replayState.positions} + eliminated={replayState.eliminated} + activeAttacks={viewMode === 'clean' ? [] : replayState.activeAttacks} + /> +
+
+
+
Event Log
+
{visibleEvents.length} shown
+
+
+ {visibleEvents.length === 0 &&
Starting positions loaded. Press Play to replay this scene round by round.
} + {visibleEvents.map((ev, i) => )} +
+
+
+ {viewMode === 'tactical' && ( +
+ ▲/▼ elevation + white bars = doors + slash = light cover + crosshatch = heavy cover + blue overlay = darkness + dashed = hazard + blue attacks = players + pink attacks = enemies + ✦ ranged · ⚔ melee · ✹ explosive + 1 checker = 10m +
+ )} +
+ ); +} + function SceneRow({ scene, idx }) { - const [open, setOpen] = useState(false); const isCombat = scene.type === 'combat' || scene.cleared !== undefined; + const hasBattlemap = Boolean(scene.battlemap?.active); + const [open, setOpen] = useState(hasBattlemap); + const [mode, setMode] = useState(hasBattlemap ? 'replay' : 'log'); // 'log' | 'replay' const passed = isCombat ? scene.cleared : scene.outcome === 'success'; + const hasEvents = (scene.events || []).length > 0; return (
@@ -65,25 +562,38 @@ function SceneRow({ scene, idx }) { : {scene.enemiesRemaining} enemies remained — squad withdrew.}
)} + {hasBattlemap && ( +
+ Battlemap: {scene.battlemap.stationsOnline}/3 stations online, gene-core {scene.battlemap.geneCoreSecuredRounds}/3, extraction {scene.battlemap.extractionProgress || 0}/2, reinforcements {scene.battlemap.reinforcementWaves}, objective {scene.battlemap.objectivesMet ? 'met' : 'failed'} +
+ )} {!isCombat && scene.total != null && (
{scene.successes}/{scene.total} checks passed — {passed ? 'Success' : 'Partial'}
)} - {(scene.events || []).length > 0 && ( -
- {scene.events.map((ev, i) => ( -
- {ev.type === 'crit' && ⚡ {ev.player} — Righteous Fury on {ev.target} ({ev.dos} DoS)} - {ev.type === 'initiative' && Initiative round {ev.round}: {(ev.order || []).join(' > ')}} - {ev.type === 'kill' && ☠ {ev.player} slew {ev.target}} - {ev.type === 'down' && 💀 {ev.player} went down (round {ev.round})} - {ev.type === 'checkRoll' && {ev.player} — {ev.check} [{ev.stat}] {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}} - {ev.type === 'heroCheck' && ★ {ev.player} — {ev.check} ({ev.dos} DoS)} - {ev.type === 'complication'&& ⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}} - {ev.type === 'wound' && 🩸 {ev.player} took {ev.net} wounds from complication} - {ev.type === 'ammo' && {ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)} - {ev.type === 'enemyAttack' && {ev.enemy} {ev.mode} vs {ev.target}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}} + {hasEvents && ( +
+
+ + +
+ {mode === 'log' && ( +
+ {scene.events.map((ev, i) => )}
- ))} + )} + {mode === 'replay' && }
)}
diff --git a/src/tests/hordeCombatFourPlayersFiveHordes.test.js b/src/tests/hordeCombatFourPlayersFiveHordes.test.js new file mode 100644 index 0000000..6e51fd4 --- /dev/null +++ b/src/tests/hordeCombatFourPlayersFiveHordes.test.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import DeathwatchRoller from '../components/DeathwatchRoller'; + +jest.setTimeout(20000); + +const mockPlayers = [ + { + name: 'Alpha', + tabInfo: { + characteristics: { ag: 40, bs: 60, ws: 45, t: 40 }, + wounds: 18, + weapons: [{ name: 'Bolter', class: 'basic', damage: '2d10+5', pen: 5, rof: 4, modes: ['single', 'semi', 'full'] }], + }, + }, + { + name: 'Bravo', + tabInfo: { + characteristics: { ag: 38, bs: 58, ws: 44, t: 42 }, + wounds: 17, + weapons: [{ name: 'Bolt Pistol', class: 'pistol', damage: '2d10+5', pen: 5, rof: 3, modes: ['single', 'semi'] }], + }, + }, + { + name: 'Charlie', + tabInfo: { + characteristics: { ag: 36, bs: 55, ws: 43, t: 41 }, + wounds: 16, + weapons: [{ name: 'Plasma Gun', class: 'basic', damage: '1d10+9', pen: 8, rof: 2, modes: ['single', 'semi'] }], + }, + }, + { + name: 'Delta', + tabInfo: { + characteristics: { ag: 34, bs: 52, ws: 42, t: 40 }, + wounds: 15, + weapons: [{ name: 'Chainsword', class: 'melee', damage: '1d10+3', pen: 4, rof: 1, modes: ['single'] }], + }, + }, +]; + +const mockHordes = [ + { name: 'Mutant Horde', horde: true, magnitude: 20, wounds: 20, tb: 4, ag: 20, ws: 30, bs: 25, damage: '1d10+3' }, + { name: 'PDF Guardsman Horde', horde: true, magnitude: 24, wounds: 24, tb: 3, ag: 25, ws: 32, bs: 30, damage: '1d10+2' }, + { name: 'Rebel Horde', horde: true, magnitude: 22, wounds: 22, tb: 3, ag: 25, ws: 30, bs: 30, damage: '1d10+2' }, + { name: 'Kroot Carnivore Horde', horde: true, magnitude: 18, wounds: 18, tb: 4, ag: 35, ws: 38, bs: 30, damage: '1d10+4' }, + { name: 'Termagaunt Horde', horde: true, magnitude: 26, wounds: 26, tb: 3, ag: 30, ws: 35, bs: 30, damage: '1d10+3' }, +]; + +function mockFetch(url) { + if (url === '/api/players') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockPlayers) }); + } + if (url === '/api/bestiary/enemies') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockHordes) }); + } + if (url === '/api/missions/active/player') { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + if (url === '/api/weapons') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) }); + } + if (typeof url === 'string' && url.startsWith('/api/players/')) { + const name = decodeURIComponent(url.split('/').pop() || ''); + const player = mockPlayers.find(entry => entry.name === name); + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(player || { name }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); +} + +describe('DeathwatchRoller horde combat', () => { + beforeEach(() => { + localStorage.clear(); + fetch.mockImplementation(mockFetch); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + test('runs 4 players against 5 horde setups with working roll progression', async () => { + for (const horde of mockHordes) { + const randomValues = [0.8, 0.6, 0.4, 0.2, 0.0]; + jest.spyOn(Math, 'random').mockImplementation(() => (randomValues.length ? randomValues.shift() : 0.5)); + + const { unmount } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Horde Combat' })); + + const getHordeEnemySelect = () => screen.getByText('Horde Enemy').parentElement.querySelector('select'); + await waitFor(() => { + const select = getHordeEnemySelect(); + expect(select).not.toBeNull(); + expect(Array.from(select.querySelectorAll('option')).some(option => option.value === horde.name)).toBe(true); + }); + fireEvent.change(getHordeEnemySelect(), { target: { value: horde.name } }); + await waitFor(() => expect(getHordeEnemySelect().value).toBe(horde.name)); + fireEvent.click(screen.getByRole('button', { name: '4' })); + await waitFor(() => expect(screen.getByText('4 player(s) selected')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start Combat' })).not.toBeDisabled()); + fireEvent.click(screen.getByRole('button', { name: 'Start Combat' })); + + await waitFor(() => expect(screen.getByText(`Horde Combat: ${horde.name}`)).toBeInTheDocument()); + expect(screen.getAllByText(/Round 1/).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + await waitFor(() => expect(screen.getAllByText(/Bravo's turn/i).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + await waitFor(() => expect(screen.getAllByText(/Charlie's turn/i).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + await waitFor(() => expect(screen.getAllByText(/Delta's turn/i).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + await waitFor(() => expect(screen.getAllByText(new RegExp(`${horde.name}'s turn`, 'i')).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + await waitFor(() => expect(screen.getAllByText(/Alpha's turn/i).length).toBeGreaterThan(0)); + + await waitFor(() => { + expect(screen.getAllByText(new RegExp(`Alpha attacks|Bravo attacks|Charlie attacks|Delta attacks`, 'i')).length).toBeGreaterThan(0); + expect(screen.getByText(new RegExp(`${horde.name} hits`, 'i'))).toBeInTheDocument(); + }); + + unmount(); + jest.restoreAllMocks(); + fetch.mockImplementation(mockFetch); + } + }); +}); diff --git a/src/tests/hordeCombatPlayerMainWeapon.test.js b/src/tests/hordeCombatPlayerMainWeapon.test.js new file mode 100644 index 0000000..0113c5b --- /dev/null +++ b/src/tests/hordeCombatPlayerMainWeapon.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import DeathwatchRoller from '../components/DeathwatchRoller'; + +const mockRoster = [ + { name: 'player1' }, + { name: 'player2' }, +]; + +const mockPlayersByName = { + player1: { + name: 'player1', + tabInfo: { + characteristics: { ag: 40, ws: 55, bs: 30, t: 45 }, + wounds: { total: 18, current: 13 }, + weapons: [ + { name: 'Thunder Hammer', damage: '2d10+8 + Strength Bonus', special: 'Concussive' }, + { name: 'Bolt Pistol', class: 'Pistol', damage: '2d10+5', pen: 5 }, + { name: 'Krak Grenade', class: 'Thrown', damage: '3d10+4 Explosive', pen: 6 }, + ], + }, + }, + player2: { + name: 'player2', + tabInfo: { + characteristics: { ag: 35, ws: 40, bs: 45, t: 40 }, + wounds: { total: 16, current: 16 }, + weapons: [ + { name: 'Bolter', class: 'Basic', damage: '2d10+5', pen: 5 }, + ], + }, + }, +}; + +const mockHordes = [ + { name: 'Test Horde', horde: true, magnitude: 12, wounds: 12, tb: 4, ag: 25, ws: 30, bs: 25, damage: '1d10+3' }, +]; + +function mockFetch(url) { + if (url === '/api/players') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockRoster) }); + } + if (url === '/api/bestiary/enemies') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockHordes) }); + } + if (url === '/api/missions/active/player') { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + if (url === '/api/weapons') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) }); + } + if (typeof url === 'string' && url.startsWith('/api/players/')) { + const name = decodeURIComponent(url.split('/').pop() || ''); + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(mockPlayersByName[name] || { name }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); +} + +describe('DeathwatchRoller horde combat player main weapon handling', () => { + beforeEach(() => { + localStorage.clear(); + fetch.mockImplementation(mockFetch); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + test('uses the player main weapon with sanitized damage and working roll progression', async () => { + const randomValues = [0.3, 0.2, 0.1, 0.0, 0.4, 0.5, 0.6]; + jest.spyOn(Math, 'random').mockImplementation(() => (randomValues.length ? randomValues.shift() : 0.5)); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Horde Combat' })); + + const getHordeEnemySelect = () => screen.getByText('Horde Enemy').parentElement.querySelector('select'); + await waitFor(() => { + const select = getHordeEnemySelect(); + expect(select).not.toBeNull(); + expect(Array.from(select.querySelectorAll('option')).some(option => option.value === 'Test Horde')).toBe(true); + }); + + fireEvent.change(getHordeEnemySelect(), { target: { value: 'Test Horde' } }); + await waitFor(() => expect(getHordeEnemySelect().value).toBe('Test Horde')); + await waitFor(() => expect(screen.getByText('2 player(s) selected')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Start Combat' })); + + await waitFor(() => expect(screen.getByText('Horde Combat: Test Horde')).toBeInTheDocument()); + expect(screen.getByText('Thunder Hammer')).toBeInTheDocument(); + expect(screen.getByText('2d10+8')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Roll' })); + + await waitFor(() => { + expect(screen.getByText(/player1 attacks with Thunder Hammer/i)).toBeInTheDocument(); + expect(screen.getByText(/vs 55 WS/i)).toBeInTheDocument(); + expect(screen.getAllByText(/player2's turn/i).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/tests/simulationTabReplay.test.js b/src/tests/simulationTabReplay.test.js new file mode 100644 index 0000000..ead9405 --- /dev/null +++ b/src/tests/simulationTabReplay.test.js @@ -0,0 +1,114 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import axios from 'axios'; +import SimulationTab from '../components/SimulationTab'; + +jest.mock('axios'); + +const runResult = { + id: 91, + mission_id: 7, + mission_name: 'Secure the Gene-Vault Under Fire', + run_date: '2026-07-04T12:00:00.000Z', + players: ['Chris', 'Rafen'], + result: 'VICTORY', + xp_earned: 900, + total_rounds: 4, + total_rolls: 9, + success_rate: 67, + combat_success_rate: 61, + puzzle_success_rate: 75, + difficulty_level: 2, + enemy_profile: 'balanced', + findings: [], + story_hooks: [], + player_cards: { + Chris: { name: 'Chris', chapter: 'Ultramarines', spec: 'Tactical', alive: true, woundsRemaining: 15, maxWounds: 20, successCount: 3, failCount: 1, totalRolls: 4, successRate: 75, fateRemaining: 1, fateSpends: 0, combatProfile: 'balanced', conditions: [], heroMoments: [], shameList: [], ammo: [], grade: 'A' }, + Rafen: { name: 'Rafen', chapter: 'Blood Angels', spec: 'Assault', alive: true, woundsRemaining: 14, maxWounds: 20, successCount: 2, failCount: 1, totalRolls: 3, successRate: 67, fateRemaining: 1, fateSpends: 0, combatProfile: 'melee', conditions: [], heroMoments: [], shameList: [], ammo: [], grade: 'B' }, + }, + roll_feed: [ + { player: 'Chris', checkName: 'Attack', result: 32, effective: 55, success: true, dos: 3, category: 'combat' }, + ], + scene_results: [ + { + title: 'Gene-Vault Push', + type: 'combat', + cleared: true, + enemiesRemaining: 0, + battlemap: { + active: true, + stationsOnline: 2, + geneCoreSecuredRounds: 1, + extractionProgress: 1, + reinforcementWaves: 1, + objectivesMet: false, + gridWidth: 40, + gridHeight: 30, + zones: [ + { id: 'extraction', x: 1, y: 11, w: 8, h: 8, tone: 'spawn' }, + { id: 'vault', x: 24, y: 9, w: 10, h: 10, tone: 'vault' }, + ], + cover: [{ id: 'cover-1', x: 10, y: 10, w: 2, h: 2 }], + blockers: [], + positions: { + 'player:Chris': { x: 3, y: 14 }, + 'player:Rafen': { x: 5, y: 14 }, + 'enemy:Cultist Fire Team': { x: 30, y: 12 }, + }, + }, + events: [ + { type: 'battlemapMove', side: 'player', player: 'Chris', x: 6, y: 14, travelled: 3, target: 'vault' }, + { type: 'battlemapMove', side: 'enemy', actor: 'Cultist Fire Team', x: 27, y: 12, travelled: 3, target: 'Chris' }, + { type: 'battlemapAttack', attacker: 'Cultist Fire Team', target: 'Chris', mode: 'ranged', glyph: 'ranged', weapon: 'Autogun', blocked: true, reasons: ['Blocked by a wall — no line of sight'] }, + { type: 'battlemapAttack', attacker: 'Cultist Fire Team', target: 'Chris', mode: 'ranged', glyph: 'ranged', weapon: 'Autogun', reasons: ['short range', 'light cover'] }, + { type: 'kill', player: 'Chris', target: 'Cultist Fire Team' }, + ], + }, + ], +}; + +describe('SimulationTab battlemap replay', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockImplementation((url) => { + if (url === '/api/missions') return Promise.resolve({ data: [{ id: 7, name: 'Secure the Gene-Vault Under Fire' }] }); + if (url === '/api/players') return Promise.resolve({ data: [{ name: 'gm' }, { name: 'Chris' }, { name: 'Rafen' }] }); + if (url === '/api/simulations') return Promise.resolve({ data: [] }); + throw new Error(`Unexpected GET ${url}`); + }); + axios.post.mockResolvedValue({ data: runResult }); + }); + + test('shows mapped scenes immediately after running a new simulation', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: /run new simulation/i })); + fireEvent.click(screen.getByRole('button', { name: /run simulation/i })); + + expect(await screen.findByText('Gene-Vault Push')).toBeInTheDocument(); + expect(screen.getByText(/Battlemap: 2\/3 stations online/i)).toBeInTheDocument(); + const replayMap = screen.getByTestId('simulation-replay-map'); + expect(replayMap).toBeInTheDocument(); + expect(screen.getByText(/Starting positions loaded/i)).toBeInTheDocument(); + expect(within(replayMap).getByText('Chris')).toBeInTheDocument(); + expect(within(replayMap).getByText('Cultist Fire Team')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /step/i })); + fireEvent.click(screen.getByRole('button', { name: /step/i })); + fireEvent.click(screen.getByRole('button', { name: /step/i })); + + expect(screen.getByText(/Blocked by a wall/i)).toBeInTheDocument(); + expect(screen.queryByTestId('simulation-attack-marker')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /step/i })); + + expect(screen.getByText(/advanced toward vault to 6,14/i)).toBeInTheDocument(); + expect(screen.getByText(/Cultist Fire Team vs Chris \(Autogun\): short range, light cover/i)).toBeInTheDocument(); + expect(screen.getByTestId('simulation-attack-marker')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /step/i })); + + expect(screen.getByTestId('simulation-death-marker-enemy:Cultist Fire Team')).toBeInTheDocument(); + expect(within(replayMap).queryByText('Cultist Fire Team')).not.toBeInTheDocument(); + }); +});