'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); } function d10() { return d(10); } function dX(formula) { const text = String(formula || ''); const m = text.match(/(\d+)d(\d+)/); if (!m) return 4; let total = 0; for (let i = 0; i < +m[1]; i++) total += Math.floor(Math.random() * +m[2]) + 1; const rest = text.slice(m.index + m[0].length); const mods = rest.match(/[+-]\s*\d+/g) || []; for (const mod of mods) total += Number(mod.replace(/\s+/g, '')); return Math.max(1, total); } function roll(target, modifier = 0) { const effective = Math.max(5, Math.min(95, target + modifier)); const result = d100(); const success = result <= effective; const dos = success ? Math.floor((effective - result) / 10) + 1 : 0; const dof = !success ? Math.floor((result - effective) / 10) + 1 : 0; return { result, effective, success, dos, dof }; } // ─── Build player state from DB tabInfo ────────────────────────────────────── function buildPlayerState(dbPlayer) { const tab = dbPlayer.tabInfo || {}; const chars = tab.characteristics || {}; const charValue = (upper, lower, fallback = 50) => chars[upper] || chars[lower] || fallback; const woundsValue = typeof tab.wounds === 'object' && tab.wounds !== null ? Number(tab.wounds.current ?? tab.wounds.total ?? 12) : Number(tab.wounds || 12); const maxWoundsValue = typeof tab.wounds === 'object' && tab.wounds !== null ? Number(tab.wounds.total ?? tab.wounds.current ?? woundsValue) : Number(tab.wounds || woundsValue || 12); const fateValue = typeof tab.fate === 'object' && tab.fate !== null ? Number(tab.fate.current ?? tab.fate.total ?? 1) : Number(tab.fate || 1); const maxFateValue = typeof tab.fate === 'object' && tab.fate !== null ? Number(tab.fate.total ?? tab.fate.current ?? fateValue) : Number(tab.fate || fateValue || 1); const gearText = [ ...(Array.isArray(tab.gear) ? tab.gear.map(g => `${g.name || ''} ${g.note || ''}`) : []), String(tab.notes || ''), ].join(' '); const weapons = Array.isArray(tab.weapons) ? tab.weapons.map(weapon => normalizeSimWeapon(weapon, gearText)) : []; return { name: dbPlayer.name, chapter: tab.chapter || 'Unknown', spec: tab.speciality || 'Unknown', WS: charValue('WS', 'ws'), BS: charValue('BS', 'bs'), S: charValue('S', 's'), T: charValue('T', 't'), Ag: charValue('Ag', 'ag'), Int: charValue('Int', 'int'), Per: charValue('Per', 'per'), Wp: charValue('Wp', 'wp'), Fel: charValue('Fel', 'fel'), wounds: woundsValue, maxWounds: maxWoundsValue, fate: fateValue, maxFate: maxFateValue, movement: normalizeMovement(tab.movement || {}), weapons, conditions: [], fateUsed: false, rolls: [], successCount: 0, failCount: 0, fateSpends: 0, heroMoments: [], shameList: [], }; } function inferredGrenadeCount(weapon, gearText = '') { const name = String(weapon.name || '').toLowerCase(); if (!/grenade/.test(name)) return 0; const kind = /krak/.test(name) ? 'krak' : (/frag/.test(name) ? 'frag' : ''); const escapedKind = kind ? `${kind}\\s+` : ''; const re = new RegExp(`(\\d+)\\s+(?:astartes\\s+)?${escapedKind}grenades?`, 'i'); const match = gearText.match(re); return Number(match?.[1] || 3); } function defaultClipForWeapon(weapon) { const name = String(weapon.name || '').toLowerCase(); if (/grenade/.test(name)) return 0; if (/bolt pistol/.test(name)) return 14; if (/heavy bolter/.test(name)) return 60; if (/bolter|boltgun/.test(name)) return 28; return 0; } function defaultDamageForWeapon(weapon) { const name = String(weapon.name || '').toLowerCase(); if (weapon.damage) return weapon.damage; if (/krak.*grenade/.test(name)) return '3d10+4'; if (/frag.*grenade/.test(name)) return '2d10+4'; if (/bolt pistol/.test(name)) return '2d10+5'; if (/bolter|boltgun/.test(name)) return '2d10+5'; if (/crozius/.test(name)) return '1d10+10'; if (/combat knife/.test(name)) return '1d10+2'; return '1d5'; } function normalizeSimWeapon(weapon, gearText = '') { const explicitClip = Number(String(weapon.clip ?? '').match(/\d+/)?.[0] || 0); const grenadeCount = inferredGrenadeCount(weapon, gearText); const clip = explicitClip || grenadeCount || defaultClipForWeapon(weapon); const className = String(weapon.class || '').toLowerCase(); const weaponName = String(weapon.name || '').toLowerCase(); const gearLower = String(gearText || '').toLowerCase(); const weaponText = `${weapon.name || ''} ${weapon.special || ''}`.toLowerCase(); const isGrenade = /grenade/.test(String(weapon.name || '').toLowerCase()); const isMelee = !isGrenade && (className.includes('melee') || String(weapon.range || '').trim() === 'melee'); const usesAmmo = !isMelee && clip > 0; const isBoltWeapon = /bolt|bolter/.test(weaponName); const isBoltPistol = /bolt pistol/.test(weaponName); const gearKrakenForWeapon = isBoltPistol ? /(?:(?:bolt\s+)?pistol\s+(?:with|using|loaded\s+with)\s+kraken|kraken\s+rounds?\s+for\s+(?:bolt\s+)?pistol)/.test(gearLower) : /(?:(?:bolter|boltgun)\s+(?:with|using|loaded\s+with)\s+kraken|kraken\s+rounds?\s+for\s+(?:bolter|boltgun))/.test(gearLower); const hasKraken = usesAmmo && isBoltWeapon && (/kraken/.test(weaponText) || gearKrakenForWeapon); const ammoType = hasKraken ? 'Kraken' : 'Standard'; return { ...weapon, damage: defaultDamageForWeapon(weapon), range: weapon.range || `${normalizeWeaponRange(weapon)}m`, isGrenade, clipSize: usesAmmo ? clip : 0, ammoRemaining: usesAmmo ? clip : null, ammoType, specialAmmo: hasKraken, shotsFired: 0, }; } function weaponUsesAmmo(weapon) { return Number(weapon?.clipSize || 0) > 0 && weapon.ammoRemaining !== null; } function weaponIsMelee(weapon) { return String(weapon?.class || '').toLowerCase().includes('melee') || String(weapon?.range || '').trim().toLowerCase() === 'melee'; } 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); } function parseRof(weapon) { if (weaponIsGrenade(weapon)) return { single: true, semi: null, full: null }; const rof = String(weapon?.rof || '').trim(); const parts = rof.split('/').map(part => part.trim()); const single = value => /^s$/i.test(value || '') || /^single$/i.test(value || ''); const numeric = value => { if (!value || value === '-' || /^s$/i.test(value) || /^single$/i.test(value)) return null; const n = Number(String(value).match(/\d+/)?.[0] || 0); return n > 0 ? n : null; }; return { single: rof ? single(parts[0]) : weaponUsesAmmo(weapon), semi: numeric(parts[1]), full: numeric(parts[2]), }; } function chooseFireMode(weapon) { if (!weaponUsesAmmo(weapon)) return { mode: 'melee', shots: 1 }; const rof = parseRof(weapon); const available = Math.max(0, Number(weapon.ammoRemaining || 0)); const options = [ rof.full ? { mode: 'full_auto', shots: rof.full } : null, rof.semi ? { mode: 'semi_auto', shots: rof.semi } : null, rof.single ? { mode: 'single', shots: 1 } : null, ].filter(Boolean).filter(option => option.shots <= available); if (!options.length) return { mode: 'dry', shots: 0 }; if (options.length === 1) return options[0]; const roll = Math.random(); if (options[0]?.mode === 'full_auto' && roll < 0.35) return options[0]; const semi = options.find(option => option.mode === 'semi_auto'); if (semi && roll < 0.80) return semi; return options[options.length - 1]; } function fireModeAttackModifier(mode) { if (mode === 'semi_auto') return 10; if (mode === 'full_auto') return 20; return 0; } function hitsForFireMode(mode, degreesOfSuccess, shotsDeclared) { 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, distanceToTarget = 0) { const profile = enemy.combatProfile || 'balanced'; const ws = Number(enemy.ws || 45); const bs = Number(enemy.bs || 45); 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 && 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 && distanceToTarget <= 2.5 ? { mode: 'melee', stat: 'WS', target: ws, range: 2 } : { mode: 'ranged', stat: 'BS', target: bs, range: rangedRange }; } 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 = [], 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); const grenadeChance = profile === 'ranged' ? 0.32 : (profile === 'melee' ? 0.12 : 0.24); if (availableGrenades.length && enemies.filter(e => e.wounds > 0).length > 1 && Math.random() < grenadeChance) { return availableGrenades.find(w => /frag/i.test(w.name || '')) || availableGrenades[0]; } if (availableGrenades.length && enemies.some(e => Number(e.wounds || 0) >= 14) && Math.random() < grenadeChance * 0.75) { return availableGrenades.find(w => /krak/i.test(w.name || '')) || availableGrenades[0]; } const bestMelee = weapons.find(weaponIsMelee); 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') { const meleeScore = (player.WS || 0) + (bestMelee ? 5 : -100); const rangedScore = (player.BS || 0) + (bestRanged ? 5 : -100); if (meleeScore > rangedScore) return bestMelee; if (bestRanged) return bestRanged; } if (profile === 'balanced') { const meleeScore = bestMelee ? (player.WS || 0) : -100; const rangedScore = bestRanged ? (player.BS || 0) : -100; if (meleeScore >= rangedScore + 15 && Math.random() < 0.65) return bestMelee; if (bestRanged) return bestRanged; if (bestMelee) return bestMelee; } return weapons.find(w => weaponUsesAmmo(w) && Number(w.ammoRemaining || 0) > 0) || weapons.find(weaponIsMelee) || weapons[0] || { name: 'Unarmed', damage: '1d5', class: 'Melee', type: 'melee', pen: 0 }; } function spendAmmo(weapon, amount = 1) { if (!weaponUsesAmmo(weapon)) return { spent: 0, remaining: null }; const available = Math.max(0, Number(weapon.ammoRemaining || 0)); const spent = Math.min(available, amount); weapon.ammoRemaining = available - spent; weapon.shotsFired = Number(weapon.shotsFired || 0) + spent; return { spent, remaining: weapon.ammoRemaining }; } function ammoSummary(player) { return (Array.isArray(player.weapons) ? player.weapons : []) .filter(weaponUsesAmmo) .map(w => ({ name: w.name || 'Weapon', ammoType: w.ammoType || 'Standard', specialAmmo: Boolean(w.specialAmmo), isGrenade: Boolean(w.isGrenade), clipSize: Number(w.clipSize || 0), remaining: Number(w.ammoRemaining || 0), shotsFired: Number(w.shotsFired || 0), })); } function isAlive(p) { return p.wounds > 0; } function addCondition(p, cond) { if (!p.conditions.includes(cond)) p.conditions.push(cond); } function removeCondition(p, cond) { const i = p.conditions.indexOf(cond); if (i !== -1) p.conditions.splice(i, 1); } function conditionMod(p, type) { let m = 0; for (const c of p.conditions) { if (c === 'Pinned') m += type === 'BS' ? -20 : 0; if (c === 'Stunned') m += -20; if (c === 'Knocked Down') m += -20; if (c === 'Fatigued') m += -10; if (c === 'Grappled') m += type === 'WS' ? -20 : 0; } return m; } function dealDamage(player, dmgFormula, armorPen, extraAbsorption = 0) { const dmg = dX(dmgFormula); 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 }; } function spendFate(player, checkName) { if (player.fate > 0 && !player.fateUsed) { player.fate--; player.fateUsed = true; player.fateSpends++; return true; } return false; } // ─── Perform one skill check ────────────────────────────────────────────────── function performCheck(player, checkName, statName, modifier, rollFeed, sceneTitle, category = 'puzzle') { const base = player[statName] || 50; const cMod = conditionMod(player, statName === 'WS' ? 'WS' : 'BS'); const r = roll(base, modifier + cMod); const entry = { player: player.name, checkName, category, sceneName: sceneTitle, statName, base, modifier, result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof, fateReroll: false, }; // Auto fate-spend on catastrophic failure (3+ DoF) if (!r.success && r.dof >= 3 && spendFate(player, checkName)) { const r2 = roll(base, modifier + cMod); Object.assign(r, r2); entry.fateReroll = true; entry.result = r.result; entry.effective = r.effective; entry.success = r.success; entry.dos = r.dos; entry.dof = r.dof; } rollFeed.push(entry); if (r.success) player.successCount++; else player.failCount++; if (r.success && r.dos >= 3) player.heroMoments.push(`${checkName} (${r.dos} DoS) in ${sceneTitle}`); if (!r.success && r.dof >= 3) player.shameList.push(`Botched ${checkName} in ${sceneTitle}`); return r; } // ─── Fear test ─────────────────────────────────────────────────────────────── function fearTest(player, fearRating, sceneTitle, rollFeed) { const penalty = [0, 0, -10, -20, -30, -40][fearRating] || 0; const r = roll(player.Wp, penalty + conditionMod(player, 'WS')); rollFeed.push({ player: player.name, checkName: `Fear Test (Rating ${fearRating})`, category: 'fear', sceneName: sceneTitle, statName: 'Wp', base: player.Wp, modifier: penalty, result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof, }); if (!r.success) { if (r.dof >= 4) { addCondition(player, 'Stunned'); player.wounds -= 2; player.shameList.push(`Broke under Fear ${fearRating} in ${sceneTitle}`); } else if (r.dof >= 2) { addCondition(player, 'Fatigued'); player.shameList.push(`Failed fear test in ${sceneTitle}`); } } else { if (r.dos >= 3) player.heroMoments.push(`Withstood Fear ${fearRating} with ${r.dos} DoS in ${sceneTitle}`); if (r.success) player.successCount++; } if (!r.success) player.failCount++; 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, 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 })), ...enemies.map(e => ({ name: e.name, total: (e.agBonus || 4) + d10(), isNpc: true })), ].sort((a, b) => b.total - a.total); events.push({ type: 'initiative', round: roundNum, order: initiatives.map(i => `${i.name}(${i.total})`) }); for (const actor of initiatives) { if (actor.isNpc) { const enemy = enemies.find(e => e.name === actor.name); if (!enemy || enemy.wounds <= 0) continue; 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 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 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: enemyTarget, distance: Number(targetDistance || 0).toFixed(1), reasons: positioning?.reasons || [], hit, }); if (hit) { 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)) { target.shameList.push(`Went down in ${sceneTitle} round ${roundNum}`); events.push({ type: 'down', player: target.name, round: roundNum, scene: sceneTitle }); } } } } 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) { const chk = checks[Math.floor(Math.random() * checks.length)]; const r = performCheck(player, chk.name, chk.stat, (chk.modifier || 0) + diffCheckMod, rollFeed, sceneTitle, 'combat'); events.push({ type: 'checkRoll', player: player.name, check: chk.name, stat: chk.stat, roll: r.result, tn: r.effective, success: r.success, dos: r.dos, dof: r.dof, }); } else { 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 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 + mapMod, result: r.result, effective: r.effective, success: r.success, dos: r.dos, dof: r.dof, fireMode: fireMode.mode, fireModeModifier: modeMod, shotsDeclared: fireMode.shots, hits, ammoType: weapon.ammoType || 'Standard', 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({ type: 'ammo', player: player.name, weapon: weapon.name, ammoType: weapon.ammoType || 'Standard', specialAmmo: Boolean(weapon.specialAmmo), fireMode: fireMode.mode, spent: ammo.spent, 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) { 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; } 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 }); } if (firstEnemy.wounds <= 0) { events.push({ type: 'kill', player: player.name, target: firstEnemy.name, scene: sceneTitle }); } } } else { player.failCount++; if (r.dof >= 3) { addCondition(player, 'Pinned'); 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'); } } } // ─── Infer checks from scene ────────────────────────────────────────────────── const STAT_FOR_CHAR = { WS:'WS', BS:'BS', S:'S', T:'T', Ag:'Ag', Int:'Int', Per:'Per', Wp:'Wp', Fel:'Fel', Str:'S', Per2:'Per' }; function statFromCheck(check) { // Map characteristic label to player stat key const char = (check.characteristic || 'Int').trim(); return STAT_FOR_CHAR[char] || char; } // Assign scene checks round-robin to living players, picking the best stat function assignChecks(scene, players) { const checks = scene.checks || []; if (!checks.length) return []; const alive = players.filter(isAlive); if (!alive.length) return []; return checks.map((check, i) => { const stat = statFromCheck(check); // Pick the player with the best stat for this check const best = alive.reduce((a, b) => ((a[stat] || 50) >= (b[stat] || 50) ? a : b)); return { player: best.name, check, stat, modifier: check.modifier || 0 }; }); } // ─── Derive enemy pool from scene ──────────────────────────────────────────── function enemiesFromScene(scene, sceneIndex, diff, enemyProfile = 'balanced') { const d = diff || DIFFICULTY[2]; const raw = scene.enemies || []; const baseWounds = [6, 8, 10, 12, 14, 16, 18, 22, 30, 8][sceneIndex] || 10; const baseBs = [45, 45, 50, 45, 50, 55, 55, 50, 60, 40][sceneIndex] || 45; const baseAp = [0, 0, 0, 0, 2, 2, 3, 3, 4, 0][sceneIndex] || 0; const baseDmg = ['1d5+2','1d5+3','1d5+3','1d5+2','1d5+3','1d10+4','1d10+6','1d10+5','1d10+8','1d5'][sceneIndex] || '1d5+3'; const scaleW = (w) => Math.max(1, Math.round(w * d.woundsMult)); const scaleBs = (bs) => Math.min(85, Math.max(5, bs + d.bsMod)); let enemies; 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)), ap: e.ap || baseAp, damage: e.damage || baseDmg, agBonus: e.agBonus || 4 + Math.floor(ei / 2), combatProfile: enemyProfile, })); } 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', 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' }, ]; } if (d.extraEnemy) { enemies.push({ name: 'Elite Guard', wounds: scaleW(Math.round(baseWounds * 0.9)), bs: scaleBs(baseBs), ws: scaleBs(baseBs + 5), ap: baseAp + 1, damage: baseDmg, agBonus: 5, combatProfile: enemyProfile, }); } return enemies; } function fearRatingFromScene(scene) { if (scene.combatState?.fearRating) return scene.combatState.fearRating; if (scene.type === 'boss' || (scene.title || '').toLowerCase().includes('boss')) return 4; if (scene.type === 'combat') return 2; if (scene.type === 'puzzle' || scene.type === 'investigation') return 1; return 0; } 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(' '); else if (value && typeof value === 'object') text = Object.values(value).map(v => compactStoryText(v)).filter(Boolean).join(' '); else text = String(value || ''); text = text.replace(/\s+/g, ' ').trim(); return text ? text.slice(0, 260) : fallback; } function firstSceneSecret(scene) { return compactStoryText(scene?.secret || scene?.secrets || scene?.hidden || scene?.gmNotes || scene?.gm_notes || scene?.gmOnly || scene?.gm_only); } function storyHookForScene(scene, index, scenes, mission) { const hook = scene?.storyHook && typeof scene.storyHook === 'object' ? scene.storyHook : {}; const title = titleForScene(scene, index); const next = scenes[index + 1]; const nextTitle = next ? titleForScene(next, index + 1) : 'the mission debrief'; const clue = compactStoryText(hook.clue || hook.reveal || hook.text); const secret = compactStoryText(hook.secret || firstSceneSecret(scene)); return { thread: compactStoryText(hook.thread, `Main thread for ${mission?.name || 'the mission'}`), clue: clue || `The result in ${title} points toward the main mission threat.`, secret: secret || `The key information in ${title} points toward ${nextTitle}.`, transition: compactStoryText(hook.transition, next ? `Point the kill-team toward ${nextTitle}.` : 'Frame the mission result and the next assignment.'), gmAction: compactStoryText(hook.gmAction || hook.gm_action, 'Reveal the clue on a successful skill check; on failure, reveal a partial clue with a cost.'), failForward: compactStoryText(hook.failForward || hook.fail_forward, 'Failure should add pressure or cost without blocking the main story.'), }; } function storyHookFromCheck({ scene, sceneIndex, scenes, mission, assignment, player, rollResult }) { const hook = storyHookForScene(scene, sceneIndex, scenes, mission); const reward = compactStoryText(assignment?.check?.reward); const checkName = assignment?.check?.name || 'scene check'; if (rollResult.success) { return { scene_index: sceneIndex, scene: titleForScene(scene, sceneIndex), type: rollResult.dos >= 3 ? 'major_reveal' : 'reveal', trigger: `${player.name} succeeded ${checkName}${rollResult.dos >= 3 ? ` with ${rollResult.dos} DoS` : ''}`, thread: hook.thread, clue: reward || hook.clue, secret: hook.secret, gm_action: hook.gmAction, transition: hook.transition, }; } return { scene_index: sceneIndex, scene: titleForScene(scene, sceneIndex), type: 'fail_forward', trigger: `${player.name} failed ${checkName} with ${rollResult.dof} DoF`, thread: hook.thread, clue: `Give a partial clue: ${reward || hook.clue}`, secret: hook.secret, gm_action: hook.failForward, transition: hook.transition, }; } function combatStoryHook({ scene, sceneIndex, scenes, mission, cleared }) { const hook = storyHookForScene(scene, sceneIndex, scenes, mission); return { scene_index: sceneIndex, scene: titleForScene(scene, sceneIndex), type: cleared ? 'combat_transition' : 'combat_escalation', trigger: cleared ? 'combat cleared' : 'combat unresolved', thread: hook.thread, clue: cleared ? hook.clue : `The team gets the clue under pressure: ${hook.clue}`, secret: hook.secret, gm_action: cleared ? `Use the victory beat to reveal the hook. ${hook.gmAction}` : `Do not stall after withdrawal. Reveal a partial hook, then apply cost: ${hook.failForward}`, transition: hook.transition, }; } // ─── Run full simulation ────────────────────────────────────────────────────── function runSimulation(mission, playerStates, diff, options = {}) { const d = diff || DIFFICULTY[2]; const enemyProfile= options.enemyProfile || 'balanced'; const rollFeed = []; const sceneResults= []; const allEvents = []; const storyHooks = []; let missionXp = 0; let totalRounds = 0; const scenes = Array.isArray(mission.scenes) ? mission.scenes : []; for (let si = 0; si < scenes.length; si++) { const scene = scenes[si]; const title = scene.title || `Scene ${si + 1}`; const type = scene.type || 'intro'; const complications = scene.complications || []; const rawFear = fearRatingFromScene(scene); const fearRating = Math.min(4, Math.max(0, rawFear + d.fearBonus)); let sceneSuccesses = 0; let sceneFailures = 0; const sceneEvents = []; // Fear tests for combat/puzzle scenes if (fearRating > 0) { for (const p of playerStates) { if (!isAlive(p)) continue; fearTest(p, fearRating, title, rollFeed); } } if (type === 'combat' || (scene.enemies && scene.enemies.length > 0)) { // Combat scene — difficulty scales enemy stats and max rounds 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 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 && playerStates.some(isAlive) && (enemies.some(e => e.wounds > 0) || !objectivesMetNow()) ) { totalRounds++; 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 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, 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); for (const a of assignments) { const player = playerStates.find(p => p.name === a.player); if (!player || !isAlive(player)) { sceneFailures++; continue; } const r = performCheck(player, a.check.name, a.stat, a.modifier + d.checkMod, rollFeed, title, 'puzzle'); sceneEvents.push({ type: 'checkRoll', player: player.name, check: a.check.name, stat: a.stat, roll: r.result, tn: r.effective, success: r.success, dos: r.dos, dof: r.dof, }); if (r.success) { sceneSuccesses++; if (r.dos >= 3) sceneEvents.push({ type: 'heroCheck', player: player.name, check: a.check.name, dos: r.dos }); storyHooks.push(storyHookFromCheck({ scene, sceneIndex: si, scenes, mission, assignment: a, player, rollResult: r })); } else { sceneFailures++; if (r.dof >= 2) storyHooks.push(storyHookFromCheck({ scene, sceneIndex: si, scenes, mission, assignment: a, player, rollResult: r })); if (r.dof >= 3 && complications.length > 0) { const comp = complications[Math.floor(Math.random() * complications.length)]; sceneEvents.push({ type: 'complication', player: player.name, text: comp }); if (/damage|wound|feeder|corrosive|spore|backlash/i.test(comp)) { const { net } = dealDamage(player, '1d5', 0); if (net > 0) sceneEvents.push({ type: 'wound', player: player.name, net, cause: comp }); } } } } const outcome = sceneSuccesses >= Math.ceil(assignments.length / 2) ? 'success' : 'partial'; missionXp += sceneSuccesses * 50 + (outcome === 'success' ? 100 : 0); sceneResults.push({ title, type, outcome, successes: sceneSuccesses, total: assignments.length, events: sceneEvents }); } allEvents.push(...sceneEvents); // Post-scene condition reset (harder difficulties offer less recovery) for (const p of playerStates) { if (p.conditions.includes('Fatigued') && Math.random() < d.healChance) removeCondition(p, 'Fatigued'); } } // Build player cards const playerCards = {}; for (const p of playerStates) { const rolls = p.successCount + p.failCount; const pct = Math.round(p.successCount / Math.max(1, rolls) * 100); playerCards[p.name] = { name: p.name, chapter: p.chapter, spec: p.spec, alive: isAlive(p), woundsRemaining:p.wounds, maxWounds: p.maxWounds, successCount: p.successCount, failCount: p.failCount, totalRolls: rolls, successRate: pct, fateRemaining: p.fate, fateSpends: p.fateSpends, combatProfile: p.combatProfile || 'balanced', roleFit: roleFitForPlayer(p), ammo: ammoSummary(p), conditions: p.conditions, heroMoments: p.heroMoments, shameList: p.shameList, grade: pct >= 70 ? 'A' : pct >= 55 ? 'B' : pct >= 40 ? 'C' : 'D', }; } const aliveCount = playerStates.filter(isAlive).length; const totalRolls = rollFeed.length; const totalSuccesses = rollFeed.filter(r => r.success).length; const successRate = Math.round(totalSuccesses / Math.max(1, totalRolls) * 100); const combatRolls = rollFeed.filter(r => r.category === 'combat'); const puzzleRolls = rollFeed.filter(r => r.category === 'puzzle'); const combatSuccessRate = Math.round(combatRolls.filter(r => r.success).length / Math.max(1, combatRolls.length) * 100); const puzzleSuccessRate = Math.round(puzzleRolls.filter(r => r.success).length / Math.max(1, puzzleRolls.length) * 100); const missionSuccess = aliveCount >= 2 && missionXp >= 800; // Auto-generate GM-facing findings. These are scripted heuristics, not AI. const findings = []; const downPlayers = playerStates.filter(p => !isAlive(p)); if (downPlayers.length > 0) findings.push(`Combat: ${downPlayers.map(p=>p.name).join(', ')} went down. As GM, reduce enemy count/wounds, add cover, or telegraph Dodge/Parry/withdraw options before the next lethal exchange.`); const heroPlayers = playerStates.filter(p => p.heroMoments.length >= 2); if (heroPlayers.length > 0) findings.push(`Table pacing: ${heroPlayers.map(p=>p.name).join(', ')} had multiple high-DoS moments. Spotlight those results with narrative rewards, bonus intel, or a visible commendation.`); if (totalRounds >= 18) findings.push('Combat: combat ran long. Lower enemy wounds by 15-25%, remove one lesser enemy, or add a mission objective that ends the fight before every enemy is killed.'); if (totalRounds > 0 && combatSuccessRate < 45) findings.push(`Combat: ${combatSuccessRate}% combat success is harsh. Add Aim/cover/bracing prompts, lower enemy BS/WS by 5-10, or give the kill-team a tactical advantage from Awareness/Tactics.`); if (totalRounds > 0 && combatSuccessRate > 80) findings.push(`Combat: ${combatSuccessRate}% combat success is very favorable. Raise pressure with cover penalties, suppression/pinning, reinforcements, or an objective timer instead of only adding wounds.`); if (combatRolls.length === 0) findings.push('Combat: no combat rolls were recorded. If this mission should test battle readiness, add at least one combat scene or enemy wave.'); if (puzzleRolls.length === 0) findings.push('Puzzle/skills: no non-combat skill checks were recorded. Add investigation, Tech-Use, Command, Medicae, Awareness, or Tactics gates so the mission is not only combat.'); if (puzzleRolls.length > 0 && puzzleSuccessRate < 45) findings.push(`Puzzle/skills: ${puzzleSuccessRate}% puzzle/check success is low. Reduce difficulty by +10/+20, allow assists, add alternate skills, or make failures reveal partial clues with complications.`); if (puzzleRolls.length > 0 && puzzleSuccessRate > 85) findings.push(`Puzzle/skills: ${puzzleSuccessRate}% puzzle/check success is very high. Add time pressure, opposed tests, layered clues, or consequences on high DoF rather than making the clue mandatory.`); const failedPuzzleCounts = puzzleRolls .filter(r => !r.success) .reduce((acc, r) => { const key = r.checkName || 'Unknown check'; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); const hardestChecks = Object.entries(failedPuzzleCounts).sort((a, b) => b[1] - a[1]).slice(0, 3); if (hardestChecks.length > 0) { findings.push(`Puzzle/skills: hardest checks were ${hardestChecks.map(([name, count]) => `${name} (${count} fail${count === 1 ? '' : 's'})`).join(', ')}. As GM, prepare alternate approaches or clearer clues for those rules moments.`); } const failedCombatStats = combatRolls .filter(r => !r.success) .reduce((acc, r) => { const key = r.statName || 'combat'; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); const weakCombatStats = Object.entries(failedCombatStats).sort((a, b) => b[1] - a[1]).slice(0, 2); if (weakCombatStats.length > 0) { findings.push(`Combat: most failed combat stats were ${weakCombatStats.map(([stat, count]) => `${stat} (${count})`).join(', ')}. Use this to decide whether the scene needs more melee threats, ranged cover, or non-attack objectives.`); } const combatScenes = sceneResults.filter(s => s.cleared !== undefined); const unclearedScenes = combatScenes.filter(s => !s.cleared); 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'); if (partialSkillScenes.length > 0) { findings.push(`Puzzle/skills: ${partialSkillScenes.length}/${Math.max(1, skillScenes.length)} skill scene${skillScenes.length === 1 ? '' : 's'} landed partial. Make failures cost time, wounds, or resources, but avoid blocking core mission progress behind one failed roll.`); } const fearRolls = rollFeed.filter(r => r.category === 'fear'); const failedFear = fearRolls.filter(r => !r.success); if (failedFear.length >= Math.max(2, Math.ceil(fearRolls.length / 2))) { findings.push('Rules pressure: fear tests are dominating the run. Consider lowering fear rating, giving preparation bonuses, or using fear as an opener instead of repeating it every scene.'); } const conditionCounts = playerStates.reduce((acc, p) => { for (const condition of p.conditions || []) acc[condition] = (acc[condition] || 0) + 1; return acc; }, {}); const commonConditions = Object.entries(conditionCounts).filter(([, count]) => count >= 2); if (commonConditions.length > 0) { findings.push(`Rules pressure: common lingering conditions were ${commonConditions.map(([name, count]) => `${name} (${count})`).join(', ')}. Add recovery beats, Medicae/Command opportunities, or clearer condition reminders.`); } const checkNames = new Set(puzzleRolls.map(r => String(r.checkName || '').toLowerCase())); if (skillScenes.length > 0 && ![...checkNames].some(name => name.includes('tactics'))) { findings.push('Puzzle/skills: no Tactics-style check appeared in non-combat scenes. Add an optional Tactics read to reveal enemy doctrine, priority targets, ambush risk, or a safer approach.'); } const attackRolls = combatRolls.filter(r => String(r.checkName || '').startsWith('Attack')); const tacticalCombatChecks = combatRolls.length - attackRolls.length; if (combatRolls.length > 0 && tacticalCombatChecks === 0) { findings.push('Combat: combat was pure attacks. Add battlefield actions such as Awareness to spot threats, Tactics to identify priority targets, Command to coordinate, or Tech-Use/Demolition to change the terrain.'); } if (playerStates.every(p => p.fateSpends === 0)) findings.push('Fate: no Fate points were spent. Difficulty may be too low, catastrophic failures may be rare, or Fate-spend affordances may need to be more visible.'); const struggling = playerStates.filter(p => (p.successCount / Math.max(1, p.successCount+p.failCount)) < 0.40); if (struggling.length) findings.push(`Player fit: ${struggling.map(p=>p.name).join(', ')} struggled below 40% success. Reassign scene checks to their strengths, add assists, or give them a role-specific objective.`); if (findings.length === 0) findings.push('Run was clean — no critical issues detected in this iteration.'); return { result: missionSuccess ? 'VICTORY' : 'PARTIAL_FAILURE', xp_earned: missionXp, total_rounds: totalRounds, total_rolls: totalRolls, success_rate: successRate, combat_success_rate: combatSuccessRate, puzzle_success_rate: puzzleSuccessRate, scene_results: sceneResults, player_cards: playerCards, roll_feed: rollFeed, story_hooks: storyHooks.slice(0, 18), findings, difficulty_level: d.level, difficulty_name: d.name, enemy_profile: enemyProfile, }; } function roleFitForPlayer(player) { const hasMelee = (player.weapons || []).some(weaponIsMelee); const hasRanged = (player.weapons || []).some(weaponIsRanged); const meleeScore = hasMelee ? Number(player.WS || 0) : 0; const rangedScore = hasRanged ? Number(player.BS || 0) : 0; const best = meleeScore > rangedScore + 10 ? 'melee' : (rangedScore > meleeScore + 10 ? 'ranged' : 'balanced'); const why = []; why.push(`WS ${player.WS || 0} vs BS ${player.BS || 0}`); if (hasMelee) why.push('has melee weapon'); if (hasRanged) why.push('has ammo weapon'); if (!hasRanged) why.push('limited ranged ammo profile'); return { best, meleeScore, rangedScore, why: why.join(', ') }; } // ─── Difficulty config ──────────────────────────────────────────────────────── const DIFFICULTY = { 1: { level: 1, name: 'Recruit', woundsMult: 0.60, bsMod: -10, checkMod: +10, maxRounds: 3, healChance: 0.70, extraEnemy: false, fearBonus: -1 }, 2: { level: 2, name: 'Standard', woundsMult: 1.00, bsMod: 0, checkMod: 0, maxRounds: 4, healChance: 0.50, extraEnemy: false, fearBonus: 0 }, 3: { level: 3, name: 'Hard', woundsMult: 1.25, bsMod: +5, checkMod: -10, maxRounds: 5, healChance: 0.30, extraEnemy: false, fearBonus: 0 }, 4: { level: 4, name: 'Brutal', woundsMult: 1.50, bsMod: +10, checkMod: -20, maxRounds: 5, healChance: 0.00, extraEnemy: true, fearBonus: +1 }, 5: { level: 5, name: 'Legendary', woundsMult: 2.00, bsMod: +15, checkMod: -30, maxRounds: 6, healChance: 0.00, extraEnemy: true, fearBonus: +2 }, }; // ─── Routes ────────────────────────────────────────────────────────────────── // POST /api/simulations — run a new simulation and save it router.post('/', async (req, res) => { try { const { mission_id, player_names, difficulty: diffInput = 2, combat_profiles = {}, enemy_profile = 'balanced' } = req.body || {}; // Load mission let mission; if (mission_id) { const all = await missionHelpers.getAll(); mission = all.find(m => m.id === Number(mission_id)); } else { mission = await missionHelpers.getActive(); } if (!mission) return res.status(404).json({ error: 'Mission not found' }); // Load players const allPlayers = await playerHelpers.getAll(); let targetNames = Array.isArray(player_names) && player_names.length ? player_names : allPlayers.filter(p => p.name !== 'gm').map(p => p.name); const playerStates = targetNames .map(name => allPlayers.find(p => p.name === name)) .filter(Boolean) .map(buildPlayerState); const allowedProfiles = new Set(['auto', 'balanced', 'melee', 'ranged']); playerStates.forEach(player => { const requested = String(combat_profiles[player.name] || combat_profiles.default || 'balanced').toLowerCase(); player.combatProfile = allowedProfiles.has(requested) ? requested : 'balanced'; }); const requestedEnemyProfile = String(enemy_profile || 'balanced').toLowerCase(); const enemyProfile = allowedProfiles.has(requestedEnemyProfile) ? requestedEnemyProfile : 'balanced'; if (!playerStates.length) return res.status(400).json({ error: 'No valid players found' }); const diff = DIFFICULTY[Math.min(5, Math.max(1, Number(diffInput) || 2))] || DIFFICULTY[2]; const simResult = runSimulation(mission, playerStates, diff, { enemyProfile }); const id = await simulationHelpers.save({ mission_id: mission.id, mission_name: mission.name, players: playerStates.map(p => p.name), ...simResult, }); if (!id) return res.status(500).json({ error: 'Failed to save simulation' }); const saved = await simulationHelpers.getById(id); logToFile(`Simulation #${id} completed: ${mission.name} — ${simResult.result}`); res.json({ ...saved, difficulty_name: diff.name }); } catch (error) { logToFile('API: Simulation run error', error); res.status(500).json({ error: String(error) }); } }); // GET /api/simulations — list all simulations (summary) router.get('/', async (req, res) => { try { const limit = Math.min(Number(req.query.limit) || 50, 200); const sims = await simulationHelpers.getAll(limit); res.json(sims.map(s => ({ ...s, difficulty_name: DIFFICULTY[s.difficulty_level || 2]?.name || 'Standard' }))); } catch (error) { logToFile('API: Error listing simulations', error); res.status(500).json({ error: String(error) }); } }); // GET /api/simulations/:id — get full simulation report router.get('/:id', async (req, res) => { try { const sim = await simulationHelpers.getById(Number(req.params.id)); if (!sim) return res.status(404).json({ error: 'Simulation not found' }); const diffLevel = sim.difficulty_level || 2; res.json({ ...sim, difficulty_name: DIFFICULTY[diffLevel]?.name || 'Standard' }); } catch (error) { logToFile('API: Error getting simulation', req.params.id, error); res.status(500).json({ error: String(error) }); } }); // DELETE /api/simulations/:id — remove a simulation router.delete('/:id', async (req, res) => { try { const ok = await simulationHelpers.delete(Number(req.params.id)); if (!ok) return res.status(404).json({ error: 'Simulation not found' }); res.json({ success: true }); } catch (error) { logToFile('API: Error deleting simulation', req.params.id, error); res.status(500).json({ error: String(error) }); } }); router.__testables = { runSimulation, buildBattlemapContext, buildPlayerState, combatRound, chooseEnemyAttackProfile, DIFFICULTY, }; module.exports = router;