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:
@@ -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;
|
||||
|
||||
@@ -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 <span className="px-2 py-0.5 rounded text-xs font-bold bg-red-800/60 text-red-200">PARTIAL FAILURE</span>;
|
||||
}
|
||||
|
||||
function EventLine({ ev }) {
|
||||
return (
|
||||
<div className="text-slate-400">
|
||||
{ev.type === 'crit' && <span className="text-yellow-300">⚡ {ev.player} — Righteous Fury on {ev.target} ({ev.dos} DoS)</span>}
|
||||
{ev.type === 'initiative' && <span className="text-slate-300">Initiative round {ev.round}: {(ev.order || []).join(' > ')}</span>}
|
||||
{ev.type === 'kill' && <span className="text-green-300">☠ {ev.player} slew {ev.target}</span>}
|
||||
{ev.type === 'down' && <span className="text-red-400">💀 {ev.player} went down (round {ev.round})</span>}
|
||||
{ev.type === 'checkRoll' && <span className={ev.success ? 'text-green-300' : 'text-red-300'}>{ev.player} — {ev.check} [{ev.stat}] {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}</span>}
|
||||
{ev.type === 'heroCheck' && <span className="text-cyan-300">★ {ev.player} — {ev.check} ({ev.dos} DoS)</span>}
|
||||
{ev.type === 'complication'&& <span className="text-orange-300">⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}</span>}
|
||||
{ev.type === 'wound' && <span className="text-red-300">🩸 {ev.player} took {ev.net} wounds from complication</span>}
|
||||
{ev.type === 'ammo' && <span className={ev.specialAmmo ? 'text-cyan-300' : 'text-slate-400'}>{ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)</span>}
|
||||
{ev.type === 'enemyAttack' && <span className={ev.hit ? 'text-red-300' : 'text-slate-500'}>{ev.enemy} {ev.mode} vs {ev.target}{ev.distance != null ? ` (${ev.distance}m)` : ''}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}</span>}
|
||||
{ev.type === 'battlemapMove' && <span className={ev.side === 'enemy' ? 'text-rose-300' : 'text-sky-300'}>{ev.actor || ev.player} advanced{ev.target ? ` toward ${ev.target}` : ''} to {ev.x},{ev.y} ({ev.travelled}m)</span>}
|
||||
{ev.type === 'battlemapAttack' && <span className={ev.blocked ? 'text-amber-300' : 'text-violet-300'}>{ev.attacker} vs {ev.target}{ev.weapon ? ` (${ev.weapon})` : ''}: {(ev.reasons || []).join(', ')}</span>}
|
||||
{ev.type === 'battlemapObjective' && <span className={ev.success ? 'text-emerald-300' : 'text-amber-300'}>{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`}</span>}
|
||||
{ev.type === 'reinforcement' && <span className="text-rose-300">↺ Reinforcement from {ev.spawn}: {ev.enemy}</span>}
|
||||
{ev.type === 'horde_damage' && <span className="text-fuchsia-300">☠ {ev.player} cut down {ev.creaturesKilled} from {ev.target}</span>}
|
||||
{ev.type === 'hazard' && <span className="text-teal-300">☣ {ev.player}: {ev.text}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950/70"
|
||||
style={{ maxHeight: 320 }}
|
||||
data-testid="simulation-replay-map"
|
||||
aria-label="Simulation battlemap replay"
|
||||
>
|
||||
<defs>
|
||||
<pattern id="sim-darkness" width="1.6" height="1.6" patternUnits="userSpaceOnUse">
|
||||
<rect width="1.6" height="1.6" fill="rgba(15,23,42,0.6)" />
|
||||
<circle cx="0.45" cy="0.45" r="0.12" fill="rgba(129,140,248,0.6)" />
|
||||
<circle cx="1.2" cy="1.1" r="0.12" fill="rgba(129,140,248,0.45)" />
|
||||
</pattern>
|
||||
<pattern id="sim-dimness" width="2" height="2" patternUnits="userSpaceOnUse">
|
||||
<rect width="2" height="2" fill="rgba(30,41,59,0.18)" />
|
||||
<line x1="0" y1="2" x2="2" y2="0" stroke="rgba(191,219,254,0.18)" strokeWidth="0.08" />
|
||||
</pattern>
|
||||
<radialGradient id="sim-glow" cx="50%" cy="50%" r="60%">
|
||||
<stop offset="0%" stopColor="rgba(196,181,253,0.48)" />
|
||||
<stop offset="55%" stopColor="rgba(168,85,247,0.22)" />
|
||||
<stop offset="100%" stopColor="rgba(30,41,59,0)" />
|
||||
</radialGradient>
|
||||
<pattern id="sim-cover-light" width="0.8" height="0.8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<line x1="0" y1="0" x2="0" y2="0.8" stroke="rgba(15,23,42,0.38)" strokeWidth="0.12" />
|
||||
</pattern>
|
||||
<pattern id="sim-cover-heavy" width="1.1" height="1.1" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="1.1" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" />
|
||||
<line x1="1.1" y1="0" x2="0" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" />
|
||||
</pattern>
|
||||
<pattern id="sim-hazard-default" width="0.9" height="0.9" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<line x1="0" y1="0" x2="0" y2="0.9" stroke="rgba(240,253,250,0.55)" strokeWidth="0.08" />
|
||||
</pattern>
|
||||
<pattern id="sim-hazard-agility" width="1.2" height="1.2" patternUnits="userSpaceOnUse">
|
||||
<path d="M0.15 1 L0.6 0.2 L1.05 1 Z" fill="rgba(255,251,235,0.45)" />
|
||||
</pattern>
|
||||
<pattern id="sim-hazard-vent" width="1.2" height="1.2" patternUnits="userSpaceOnUse">
|
||||
<circle cx="0.3" cy="0.35" r="0.12" fill="rgba(240,253,250,0.5)" />
|
||||
<circle cx="0.7" cy="0.75" r="0.14" fill="rgba(240,253,250,0.36)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
{Array.from({ length: width + 1 }, (_, index) => (
|
||||
<line
|
||||
key={`grid-v-${index}`}
|
||||
x1={index}
|
||||
y1="0"
|
||||
x2={index}
|
||||
y2={height}
|
||||
stroke="rgba(148,163,184,0.18)"
|
||||
strokeWidth="0.04"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: height + 1 }, (_, index) => (
|
||||
<line
|
||||
key={`grid-h-${index}`}
|
||||
x1="0"
|
||||
y1={index}
|
||||
x2={width}
|
||||
y2={index}
|
||||
stroke="rgba(148,163,184,0.18)"
|
||||
strokeWidth="0.04"
|
||||
/>
|
||||
))}
|
||||
{zones.map(zone => (
|
||||
<g key={zone.id}>
|
||||
<rect x={zone.x} y={zone.y} width={zone.w} height={zone.h} fill={fillForZoneTone(zone.tone)} fillOpacity="0.35" stroke={elevationStroke(zone.elevation)} strokeWidth="0.15" />
|
||||
{lightingOverlay(zone.lighting) && (
|
||||
<rect x={zone.x} y={zone.y} width={zone.w} height={zone.h} fill={lightingOverlay(zone.lighting).fill} fillOpacity={lightingOverlay(zone.lighting).opacity} />
|
||||
)}
|
||||
{elevationBadge(zone.elevation) && (
|
||||
<text x={zone.x + zone.w - 0.4} y={zone.y + zone.h - 0.3} textAnchor="end" fontSize="0.75" fill={elevationStroke(zone.elevation)}>{elevationBadge(zone.elevation)}</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
{cover.map(item => (
|
||||
<g key={item.id}>
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={coverFill(item.kind)} fillOpacity={item.kind === 'heavy' ? 0.86 : 0.64} />
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${coverPatternId(item.kind)})`} fillOpacity="0.9" />
|
||||
</g>
|
||||
))}
|
||||
{doors.map((door) => (
|
||||
<g key={door.id}>
|
||||
<rect x={door.x} y={door.y} width={door.w} height={door.h} rx="0.08" fill="#f8fafc" fillOpacity="0.95" stroke="#0f172a" strokeWidth="0.08" />
|
||||
</g>
|
||||
))}
|
||||
{(battlemap?.hazards || []).map(item => (
|
||||
<g key={item.id}>
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={item.kind === 'agility' ? '#f59e0b' : '#0f766e'} fillOpacity="0.45" stroke="#f8fafc" strokeDasharray="0.25 0.25" strokeWidth="0.12" />
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${hazardPatternId(item.kind)})`} fillOpacity="0.9" />
|
||||
</g>
|
||||
))}
|
||||
{blockers.map(item => (
|
||||
<rect key={item.id} x={item.x} y={item.y} width={item.w} height={item.h} fill="#1e293b" stroke="#475569" strokeWidth="0.12" />
|
||||
))}
|
||||
{hazards.map(item => (
|
||||
<g key={item.id}>
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={item.kind === 'agility' ? '#f59e0b' : '#0f766e'} fillOpacity="0.45" stroke="#f8fafc" strokeDasharray="0.25 0.25" strokeWidth="0.12" />
|
||||
<rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${hazardPatternId(item.kind)})`} fillOpacity="0.9" />
|
||||
</g>
|
||||
))}
|
||||
{(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 (
|
||||
<g key={`${attack.attacker}-${attack.target}-${attack.step}-${idx}`} data-testid="simulation-attack-marker" opacity={fade}>
|
||||
{(attack.glyph || attack.mode) === 'explosive' ? (
|
||||
<>
|
||||
<line x1={attack.attackerPos.x} y1={attack.attackerPos.y} x2={attack.targetPos.x} y2={attack.targetPos.y} stroke={rangedStroke} strokeWidth="0.18" strokeDasharray="0.28 0.22" strokeLinecap="round" />
|
||||
<circle cx={midX} cy={midY} r="0.85" fill={rangedFill} stroke={rangedStroke} strokeWidth="0.12" />
|
||||
<text x={midX} y={midY + 0.18} textAnchor="middle" fontSize="0.95" fill="#0f172a" style={{ fontWeight: 700 }}>✹</text>
|
||||
</>
|
||||
) : attack.mode === 'melee' ? (
|
||||
<>
|
||||
<line x1={attack.attackerPos.x} y1={attack.attackerPos.y} x2={attack.targetPos.x} y2={attack.targetPos.y} stroke={meleeStroke} strokeWidth="0.28" strokeLinecap="round" />
|
||||
<circle cx={attack.targetPos.x} cy={attack.targetPos.y} r="1.2" fill="none" stroke={meleeStroke} strokeWidth="0.18" />
|
||||
<text x={midX} y={midY + 0.15} textAnchor="middle" fontSize="0.9" fill={meleeStroke} style={{ fontWeight: 700 }}>⚔</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1={attack.attackerPos.x} y1={attack.attackerPos.y} x2={attack.targetPos.x} y2={attack.targetPos.y} stroke={rangedStroke} strokeWidth="0.22" strokeDasharray="0.5 0.35" strokeLinecap="round" />
|
||||
<polygon points={`${attack.targetPos.x},${attack.targetPos.y} ${attack.targetPos.x - 0.9},${attack.targetPos.y - 0.35} ${attack.targetPos.x - 0.9},${attack.targetPos.y + 0.35}`} fill={rangedFill} />
|
||||
<text x={midX} y={midY + 0.15} textAnchor="middle" fontSize="0.9" fill={rangedStroke} style={{ fontWeight: 700 }}>✦</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{tokens.map(([key, pos]) => {
|
||||
const isEnemy = key.startsWith('enemy:');
|
||||
const name = key.slice(key.indexOf(':') + 1);
|
||||
return (
|
||||
<g key={key}>
|
||||
<circle cx={pos.x} cy={pos.y} r="0.9" fill={isEnemy ? '#ef4444' : '#38bdf8'} stroke="#0f172a" strokeWidth="0.15" />
|
||||
<text x={pos.x} y={pos.y - 1.2} textAnchor="middle" fontSize="1.1" fill="#e2e8f0">{name}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{eliminatedTokens.map(([key, pos]) => (
|
||||
<g key={key} data-testid={`simulation-death-marker-${key}`}>
|
||||
<line x1={pos.x - 0.8} y1={pos.y - 0.8} x2={pos.x + 0.8} y2={pos.y + 0.8} stroke="#f8fafc" strokeWidth="0.2" strokeLinecap="round" />
|
||||
<line x1={pos.x + 0.8} y1={pos.y - 0.8} x2={pos.x - 0.8} y2={pos.y + 0.8} stroke="#f8fafc" strokeWidth="0.2" strokeLinecap="round" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border border-slate-700/50 bg-slate-950/40 p-2 space-y-2">
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-lg border border-slate-700/70 bg-slate-900/90 px-2 py-1.5">
|
||||
<span className="mr-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Playback</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlayClick}
|
||||
className="rounded bg-blue-700 hover:bg-blue-600 px-2.5 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{playing ? '⏸ Pause' : atEnd ? '↻ Replay' : '▶ Play'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPlaying(false); setIndex(-1); }}
|
||||
className="rounded bg-slate-700 hover:bg-slate-600 px-2.5 py-1.5 text-xs text-slate-200"
|
||||
>
|
||||
⏮ Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => Math.min(events.length - 1, i + 1))}
|
||||
disabled={playing || index >= events.length - 1}
|
||||
className="rounded bg-slate-700 hover:bg-slate-600 px-2.5 py-1.5 text-xs text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
Step ▶
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-slate-700/70 bg-slate-900/90 px-2 py-1.5 text-xs text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Speed</span>
|
||||
<select
|
||||
value={speedMs}
|
||||
onChange={e => setSpeedMs(Number(e.target.value))}
|
||||
className="bg-slate-700 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200"
|
||||
>
|
||||
{REPLAY_SPEEDS.map(s => <option key={s.ms} value={s.ms}>{s.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1 rounded-lg border border-cyan-700/60 bg-cyan-950/20 px-2 py-1.5">
|
||||
<span className="mr-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-cyan-300/75">View</span>
|
||||
{REPLAY_VIEW_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => setViewMode(mode.id)}
|
||||
className={`rounded px-2.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||
viewMode === mode.id
|
||||
? 'bg-cyan-500 text-slate-950'
|
||||
: 'bg-slate-900/80 text-slate-300 hover:bg-slate-800 hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-[8rem] items-center justify-end rounded-lg border border-slate-700/70 bg-slate-900/90 px-2.5 py-1.5 text-right">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Progress</div>
|
||||
<div className="text-sm font-semibold text-slate-200">{Math.max(0, index + 1)}/{events.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-800/80 bg-slate-900/60 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-200">Scene Replay</div>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
{viewMode === 'clean'
|
||||
? 'Clean view focuses on token movement, attacks, and removals.'
|
||||
: 'Tactical view adds cover, elevation, lighting, hazards, and attack trails.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border border-slate-700/70 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-400">
|
||||
{playing ? 'Playback running' : atEnd ? 'Replay finished' : index < 0 ? 'Ready to start' : 'Paused'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="rounded-lg border border-slate-800/80 bg-slate-950/35 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Battle Map</div>
|
||||
<div className="text-[11px] text-slate-500">{Object.keys(replayState.positions || {}).length} active tokens</div>
|
||||
</div>
|
||||
<ReplayMap
|
||||
battlemap={
|
||||
viewMode === 'clean'
|
||||
? {
|
||||
...scene.battlemap,
|
||||
cover: [],
|
||||
blockers: [],
|
||||
hazards: [],
|
||||
zones: Array.isArray(scene.battlemap?.zones)
|
||||
? scene.battlemap.zones.map((zone) => ({
|
||||
...zone,
|
||||
lighting: 'normal',
|
||||
elevation: 0,
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
: scene.battlemap
|
||||
}
|
||||
positions={replayState.positions}
|
||||
eliminated={replayState.eliminated}
|
||||
activeAttacks={viewMode === 'clean' ? [] : replayState.activeAttacks}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800/80 bg-slate-950/35 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Event Log</div>
|
||||
<div className="text-[11px] text-slate-500">{visibleEvents.length} shown</div>
|
||||
</div>
|
||||
<div ref={logRef} className="max-h-72 overflow-y-auto space-y-0.5 pr-1 text-xs">
|
||||
{visibleEvents.length === 0 && <div className="text-slate-500">Starting positions loaded. Press Play to replay this scene round by round.</div>}
|
||||
{visibleEvents.map((ev, i) => <EventLine key={i} ev={ev} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'tactical' && (
|
||||
<div className="flex flex-wrap gap-1 text-[10px] text-slate-400">
|
||||
<span className="rounded border border-yellow-500/50 bg-yellow-950/20 px-1.5 py-0.5 text-yellow-100">▲/▼ elevation</span>
|
||||
<span className="rounded border border-emerald-500/50 bg-emerald-950/20 px-1.5 py-0.5 text-emerald-100">white bars = doors</span>
|
||||
<span className="rounded border border-slate-500/50 bg-slate-800 px-1.5 py-0.5 text-slate-100">slash = light cover</span>
|
||||
<span className="rounded border border-amber-700/50 bg-amber-950/20 px-1.5 py-0.5 text-amber-100">crosshatch = heavy cover</span>
|
||||
<span className="rounded border border-indigo-500/50 bg-indigo-950/20 px-1.5 py-0.5 text-indigo-100">blue overlay = darkness</span>
|
||||
<span className="rounded border border-cyan-500/50 bg-cyan-950/20 px-1.5 py-0.5 text-cyan-100">dashed = hazard</span>
|
||||
<span className="rounded border border-sky-500/50 bg-sky-950/20 px-1.5 py-0.5 text-sky-100">blue attacks = players</span>
|
||||
<span className="rounded border border-pink-500/50 bg-pink-950/20 px-1.5 py-0.5 text-pink-100">pink attacks = enemies</span>
|
||||
<span className="rounded border border-violet-500/50 bg-violet-950/20 px-1.5 py-0.5 text-violet-100">✦ ranged · ⚔ melee · ✹ explosive</span>
|
||||
<span className="rounded border border-slate-500/50 bg-slate-900 px-1.5 py-0.5 text-slate-100">1 checker = 10m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border border-slate-700/40 rounded-lg mb-1 overflow-hidden">
|
||||
@@ -65,25 +562,38 @@ function SceneRow({ scene, idx }) {
|
||||
: <span className="text-yellow-300">{scene.enemiesRemaining} enemies remained — squad withdrew.</span>}
|
||||
</div>
|
||||
)}
|
||||
{hasBattlemap && (
|
||||
<div className="rounded border border-slate-700/40 bg-slate-950/40 px-2 py-1 text-slate-300">
|
||||
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'}
|
||||
</div>
|
||||
)}
|
||||
{!isCombat && scene.total != null && (
|
||||
<div>{scene.successes}/{scene.total} checks passed — <span className={passed ? 'text-green-300' : 'text-yellow-300'}>{passed ? 'Success' : 'Partial'}</span></div>
|
||||
)}
|
||||
{(scene.events || []).length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{scene.events.map((ev, i) => (
|
||||
<div key={i} className="text-slate-400">
|
||||
{ev.type === 'crit' && <span className="text-yellow-300">⚡ {ev.player} — Righteous Fury on {ev.target} ({ev.dos} DoS)</span>}
|
||||
{ev.type === 'initiative' && <span className="text-slate-300">Initiative round {ev.round}: {(ev.order || []).join(' > ')}</span>}
|
||||
{ev.type === 'kill' && <span className="text-green-300">☠ {ev.player} slew {ev.target}</span>}
|
||||
{ev.type === 'down' && <span className="text-red-400">💀 {ev.player} went down (round {ev.round})</span>}
|
||||
{ev.type === 'checkRoll' && <span className={ev.success ? 'text-green-300' : 'text-red-300'}>{ev.player} — {ev.check} [{ev.stat}] {ev.roll}/{ev.tn} {ev.success ? `${ev.dos} DoS` : `${ev.dof} DoF`}</span>}
|
||||
{ev.type === 'heroCheck' && <span className="text-cyan-300">★ {ev.player} — {ev.check} ({ev.dos} DoS)</span>}
|
||||
{ev.type === 'complication'&& <span className="text-orange-300">⚡ Complication ({ev.player}): {ev.text?.slice(0, 80)}</span>}
|
||||
{ev.type === 'wound' && <span className="text-red-300">🩸 {ev.player} took {ev.net} wounds from complication</span>}
|
||||
{ev.type === 'ammo' && <span className={ev.specialAmmo ? 'text-cyan-300' : 'text-slate-400'}>{ev.player} fired {ev.spent} {ev.ammoType || 'Standard'} round{ev.spent === 1 ? '' : 's'} from {ev.weapon} ({ev.fireMode}, {ev.remaining} left)</span>}
|
||||
{ev.type === 'enemyAttack' && <span className={ev.hit ? 'text-red-300' : 'text-slate-500'}>{ev.enemy} {ev.mode} vs {ev.target}: {ev.roll}/{ev.tn} {ev.hit ? 'hit' : 'miss'}</span>}
|
||||
{hasEvents && (
|
||||
<div className="mt-1">
|
||||
<div className="flex gap-1 mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('log')}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-semibold ${mode === 'log' ? 'bg-slate-700 text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
Full Log
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('replay')}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-semibold ${mode === 'replay' ? 'bg-slate-700 text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
🎬 Live Replay
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'log' && (
|
||||
<div className="space-y-0.5">
|
||||
{scene.events.map((ev, i) => <EventLine key={i} ev={ev} />)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{mode === 'replay' && <SceneReplay scene={scene} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(<DeathwatchRoller authedPlayer="gm" />);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(<DeathwatchRoller authedPlayer="player1" />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<SimulationTab />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user