diff --git a/src/tests/playthroughRun2.js b/src/tests/playthroughRun2.js new file mode 100644 index 0000000..b6f3a49 --- /dev/null +++ b/src/tests/playthroughRun2.js @@ -0,0 +1,793 @@ +#!/usr/bin/env node +/** + * Playthrough Simulation #2 — "The Hunt for Fabius Bile" + * GM + 4 players: anders, christoffer, claes, phillip + * + * Run: node src/tests/playthroughRun2.js + * + * Uses real dice rolls against actual player stats from DB. + * Tracks: wounds, fate, conditions, fear, initiative, degrees, roll feed. + */ + +'use strict'; + +// ─── ANSI colours ──────────────────────────────────────────────────────────── +const C = { + reset: '\x1b[0m', bold: '\x1b[1m', + red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', + blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', + white: '\x1b[37m', grey: '\x1b[90m', brightRed: '\x1b[91m', + brightGreen: '\x1b[92m', brightYellow: '\x1b[93m', brightBlue: '\x1b[94m', + brightMagenta: '\x1b[95m', brightCyan: '\x1b[96m', +}; +const bold = s => `${C.bold}${s}${C.reset}`; +const red = s => `${C.red}${s}${C.reset}`; +const green = s => `${C.green}${s}${C.reset}`; +const yellow = s => `${C.yellow}${s}${C.reset}`; +const cyan = s => `${C.cyan}${s}${C.reset}`; +const magenta= s => `${C.magenta}${s}${C.reset}`; +const grey = s => `${C.grey}${s}${C.reset}`; +const bRed = s => `${C.brightRed}${s}${C.reset}`; +const bGreen = s => `${C.brightGreen}${s}${C.reset}`; +const bYellow= s => `${C.brightYellow}${s}${C.reset}`; +const bCyan = s => `${C.brightCyan}${s}${C.reset}`; +const bMag = s => `${C.brightMagenta}${s}${C.reset}`; + +// ─── Dice ──────────────────────────────────────────────────────────────────── +function d(sides) { return Math.floor(Math.random() * sides) + 1; } +function d100() { return d(100); } +function d10() { return d(10); } +function dX(formula) { + // parse e.g. "2d10+4", "1d10+3", "3d10" + const m = formula.match(/(\d+)d(\d+)([+-]\d+)?/); + if (!m) return 5; + let total = 0; + for (let i = 0; i < +m[1]; i++) total += Math.floor(Math.random() * +m[2]) + 1; + if (m[3]) total += +m[3]; + 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 }; +} + +function rollLabel(r) { + if (r.success) { + if (r.dos >= 4) return bGreen(`✅ CRITICAL SUCCESS (${r.result}/${r.effective}, ${r.dos} DoS)`); + if (r.dos >= 2) return green(`✅ Success (${r.result}/${r.effective}, ${r.dos} DoS)`); + return green(`✅ Narrow success (${r.result}/${r.effective})`); + } else { + if (r.dof >= 4) return bRed(`❌ CATASTROPHIC FAIL (${r.result}/${r.effective}, ${r.dof} DoF)`); + if (r.dof >= 2) return red(`❌ Failure (${r.result}/${r.effective}, ${r.dof} DoF)`); + return red(`❌ Narrow failure (${r.result}/${r.effective})`); + } +} + +// ─── Characters ────────────────────────────────────────────────────────────── +const PLAYERS = { + anders: { + name: 'anders', chapter: 'Blood Angels', spec: 'Devastator', + WS:60, BS:60, S:70, T:70, Ag:50, Int:60, Per:60, Wp:60, Fel:50, + wounds: 15, maxWounds: 15, fate: 1, maxFate: 1, + skills: ['Awareness', 'Intuition'], + weapons: [{name:'Heavy Bolter', damage:'2d10+4', ap:4, type:'heavy'}, + {name:'Bolt Pistol', damage:'1d10+3', ap:4, type:'pistol'}], + conditions: [], fateUsed: false, + rolls: [], successCount: 0, failCount: 0, fateSpends: 0, + heroMoments: [], shameList: [], + }, + christoffer: { + name: 'christoffer', chapter: 'Ultramarines', spec: 'Chaplain', + WS:75, BS:40, S:80, T:80, Ag:50, Int:70, Per:60, Wp:85, Fel:50, + wounds: 15, maxWounds: 15, fate: 1, maxFate: 1, + skills: ['Empathy', 'Leadership', 'Intuition'], + weapons: [{name:'Crozius Arcanum', damage:'2d10', ap:4, type:'melee'}, + {name:'Bolt Pistol', damage:'1d10+3', ap:4, type:'pistol'}], + conditions: [], fateUsed: false, + rolls: [], successCount: 0, failCount: 0, fateSpends: 0, + heroMoments: [], shameList: [], + }, + claes: { + name: 'claes', chapter: 'Ultramarines', spec: 'Tactical', + WS:70, BS:60, S:80, T:80, Ag:50, Int:60, Per:60, Wp:60, Fel:50, + wounds: 15, maxWounds: 15, fate: 1, maxFate: 1, + skills: ['Stealth', 'Awareness'], + weapons: [{name:'Bolter', damage:'1d10+4', ap:4, type:'ranged'}, + {name:'Chainsword', damage:'1d10+2', ap:4, type:'melee'}, + {name:'Power Fist', damage:'3d10', ap:4, type:'melee'}, + {name:'Bolt Pistol', damage:'1d10+3', ap:4, type:'pistol'}], + conditions: [], fateUsed: false, + rolls: [], successCount: 0, failCount: 0, fateSpends: 0, + heroMoments: [], shameList: [], + }, + phillip: { + name: 'phillip', chapter: 'Ultramarines', spec: 'Tactical', + WS:60, BS:60, S:70, T:70, Ag:50, Int:60, Per:60, Wp:60, Fel:50, + wounds: 15, maxWounds: 15, fate: 1, maxFate: 1, + skills: ['Awareness', 'Intuition'], + weapons: [{name:'Bolter', damage:'1d10+4', ap:4, type:'ranged'}, + {name:'Chainsword', damage:'1d10+2', ap:4, type:'melee'}, + {name:'Bolt Pistol', damage:'1d10+3', ap:4, type:'pistol'}], + conditions: [], fateUsed: false, + rolls: [], successCount: 0, failCount: 0, fateSpends: 0, + heroMoments: [], shameList: [], + }, +}; + +const GM = { name: 'GM (Hestus)', rollFeed: [] }; + +// ─── State ─────────────────────────────────────────────────────────────────── +let sceneIndex = 0; +let missionXp = 0; +let totalRounds = 0; +const MISSION_LOG = []; + +// ─── Helpers ───────────────────────────────────────────────────────────────── +function log(msg) { console.log(msg); MISSION_LOG.push(msg.replace(/\x1b\[\d+m/g, '')); } +function sep(char = '─', len = 72) { log(grey(char.repeat(len))); } +function header(title) { + sep('═'); + log(`${bold(bCyan(' ' + title))}`); + sep('═'); +} +function subheader(title) { + sep(); + log(bold(yellow(` ${title}`))); + sep(); +} + +function stat(player, statName) { return player[statName] || 0; } + +function addCondition(player, cond) { + if (!player.conditions.includes(cond)) { + player.conditions.push(cond); + log(` ${yellow('⚠')} ${bold(player.name)} gains condition: ${bYellow(cond)}`); + } +} + +function removeCondition(player, cond) { + const i = player.conditions.indexOf(cond); + if (i !== -1) { + player.conditions.splice(i, 1); + log(` ${green('✦')} ${bold(player.name)} shakes off: ${green(cond)}`); + } +} + +function conditionMod(player, type = 'BS') { + let m = 0; + for (const c of player.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 = 0) { + const dmg = dX(dmgFormula); + const absorption = Math.max(0, 6 - armorPen); // Power Armour AV 6 base + const net = Math.max(0, dmg - absorption); + player.wounds -= net; + return { dmg, absorption, net }; +} + +function isAlive(player) { return player.wounds > 0; } + +function checkFate(player, checkName) { + if (player.fate > 0 && !player.fateUsed) { + player.fate--; + player.fateUsed = true; + player.fateSpends++; + log(` ${bMag('✧ FATE SPENT')} — ${bold(player.name)} burns a Fate Point to re-roll ${bold(checkName)}!`); + return true; + } + return false; +} + +function recordRoll(player, checkName, r, sceneName) { + const entry = { checkName, sceneName, ...r, time: new Date().toISOString() }; + player.rolls.push(entry); + GM.rollFeed.push({ player: player.name, ...entry }); + if (r.success) player.successCount++; else player.failCount++; + return entry; +} + +function performCheck(player, checkName, statName, baseTarget, modifier = 0, allowFate = true) { + const base = stat(player, statName); + const cMod = conditionMod(player, statName === 'WS' ? 'WS' : 'BS'); + const total = base + modifier + cMod; + let r = roll(total); + log(` ${grey('↳')} ${bold(player.name)} [${cyan(checkName)}] ${statName}:${base}${modifier !== 0 ? (modifier > 0 ? '+' : '')+modifier : ''}${cMod !== 0 ? (cMod > 0 ? '+' : '')+cMod : ''} → ${rollLabel(r)}`); + recordRoll(player, checkName, r, ''); + + // Fate re-roll on critical failure + if (!r.success && r.dof >= 3 && allowFate && checkFate(player, checkName)) { + r = roll(total); + log(` ${grey('↳ Re-roll:')} ${rollLabel(r)}`); + recordRoll(player, checkName, r, ''); + } + return r; +} + +function fearTest(player, fearRating, sceneTitle) { + const penalty = [0, 0, -10, -20, -30, -40][fearRating] || 0; + const wpBase = player.Wp; + const cMod = conditionMod(player, 'WS'); + const target = wpBase + penalty + cMod; + const r = roll(target); + log(` ${bYellow('☠')} ${bold(player.name)} FEAR TEST (Wp${penalty}) → ${rollLabel(r)}`); + if (!r.success) { + const dof = r.dof; + if (dof >= 4) { + log(` ${bRed('BROKEN!')} — ${bold(player.name)} is overcome with existential dread.`); + addCondition(player, 'Stunned'); + player.wounds -= 2; + log(` Takes 2 Shock wounds (${player.wounds}/${player.maxWounds} remaining)`); + player.shameList.push(`Broke under Fear ${fearRating} in ${sceneTitle}`); + } else if (dof >= 2) { + log(` ${red('Rattled.')} — ${bold(player.name)} is shaken.`); + addCondition(player, 'Fatigued'); + player.shameList.push(`Failed fear test in ${sceneTitle}`); + } else { + log(` ${yellow('Unsteady.')} — ${bold(player.name)} grits teeth but hesitates.`); + } + } else { + if (r.dos >= 3) { + log(` ${bGreen('Unshakeable.')} — ${bold(player.name)} channels righteous fury.`); + player.heroMoments.push(`Withstood Fear ${fearRating} with ${r.dos} DoS in ${sceneTitle}`); + } + } + return r; +} + +function rollInitiative(player) { + const agBonus = Math.floor(stat(player, 'Ag') / 10); + const die = d10(); + return { name: player.name, total: agBonus + die, die, agBonus }; +} + +function combatRound(roundNum, players, enemies, sceneTitle, checkList) { + log(''); + subheader(`⚔ ROUND ${roundNum}`); + totalRounds++; + + // Initiative + const initiatives = players.map(rollInitiative); + enemies.forEach(e => { + const die = d10(); + initiatives.push({ name: e.name, total: e.agBonus + die, die, agBonus: e.agBonus, isNpc: true }); + }); + initiatives.sort((a, b) => b.total - a.total); + log(grey(' Initiative order: ') + initiatives.map(i => { + const tag = i.isNpc ? bRed(i.name) : bold(i.name); + return `${tag}(${i.total})`; + }).join(grey(' → '))); + + let sceneSuccesses = 0; + let sceneFailures = 0; + + for (const actor of initiatives) { + if (actor.isNpc) { + // NPC attacks random player + const target = players[Math.floor(Math.random() * players.length)]; + if (!isAlive(target)) continue; + const enemy = enemies.find(e => e.name === actor.name); + const atkRoll = d100(); + const hit = atkRoll <= enemy.bs; + log(` ${bRed(enemy.name)} attacks ${bold(target.name)}: ${atkRoll}/${enemy.bs} → ${hit ? red('HIT') : green('MISS')}`); + if (hit) { + const { dmg, absorption, net } = dealDamage(target, enemy.damage, enemy.ap || 0); + if (net > 0) { + log(` ${red(`${enemy.name} deals ${dmg} damage (${net} net after AP:${absorption}) → ${target.name} ${target.wounds}/${target.maxWounds} wounds`)}`); + if (net >= 5) { + addCondition(target, d(2) === 1 ? 'Pinned' : 'Stunned'); + } + if (target.wounds <= 0) { + log(` ${bRed('💀 CRITICAL — ' + target.name.toUpperCase() + ' IS DOWN!')}`); + target.shameList.push(`Went down in ${sceneTitle} round ${roundNum}`); + } + } else { + log(` ${green('Power Armour absorbs the blow.')}`); + } + } + } else { + // Player attack or check + const player = players.find(p => p.name === actor.name); + if (!player || !isAlive(player)) continue; + if (player.conditions.includes('Stunned')) { + log(` ${bold(player.name)} is ${yellow('Stunned')} — loses their action.`); + removeCondition(player, 'Stunned'); + continue; + } + + // Pick check or attack + if (checkList.length > 0 && Math.random() < 0.5) { + const chk = checkList[Math.floor(Math.random() * checkList.length)]; + const r = performCheck(player, chk.name, chk.stat, player[chk.stat], chk.modifier || 0); + if (r.success) sceneSuccesses++; else sceneFailures++; + if (r.success && r.dos >= 3) player.heroMoments.push(`${chk.name} (${r.dos} DoS) in ${sceneTitle}`); + if (!r.success && r.dof >= 3) player.shameList.push(`Botched ${chk.name} in ${sceneTitle}`); + } else { + // Attack nearest enemy + const enemy = enemies[0]; + if (!enemy || enemy.wounds <= 0) continue; + const isRanged = player.weapons[0]?.type !== 'melee'; + const isGrappled = player.conditions.includes('Grappled'); + if (isRanged && isGrappled) { + log(` ${bold(player.name)} is Grappled — switches to melee!`); + } + const weapon = (isRanged && !isGrappled) ? player.weapons[0] : player.weapons.find(w => w.type === 'melee') || player.weapons[0]; + const statName = (weapon.type === 'melee' || weapon.type === 'pistol') ? 'WS' : 'BS'; + const base = player[statName]; + const cMod = conditionMod(player, statName); + const r = roll(base + cMod); + log(` ${bold(player.name)} attacks with ${cyan(weapon.name)}: ${statName}:${base}${cMod !== 0 ? (cMod>0?'+':'')+cMod : ''} → ${rollLabel(r)}`); + if (r.success) { + const dmg = dX(weapon.damage); + const dos = r.dos; + const totalDmg = dmg + (dos > 1 ? dos - 1 : 0); + enemy.wounds -= totalDmg; + log(` ${green('HIT!')} ${weapon.name} deals ${bold(totalDmg)} damage to ${red(enemy.name)} (${Math.max(0,enemy.wounds)} remaining)`); + player.successCount++; + if (dos >= 4) { + player.heroMoments.push(`Critical hit on ${enemy.name} with ${weapon.name} (${r.dos} DoS) in ${sceneTitle}`); + log(` ${bGreen('⚡ RIGHTEOUS FURY')} — a kill-blow worthy of the Emperor!`); + } + if (enemy.wounds <= 0) { + log(` ${bGreen('☠ ' + enemy.name.toUpperCase() + ' IS SLAIN!')}`); + } + } else { + player.failCount++; + if (r.dof >= 3) { + addCondition(player, 'Pinned'); + player.shameList.push(`Exposed flank against ${enemy.name} in ${sceneTitle}`); + } + } + } + + // Clear Pinned if checked aggressively + if (player.conditions.includes('Pinned') && Math.random() < 0.4) { + removeCondition(player, 'Pinned'); + } + } + } + return { sceneSuccesses, sceneFailures }; +} + +// ─── Scene Runners ─────────────────────────────────────────────────────────── + +function runSkillScene(sceneNum, title, type, description, checks, complications, assignedChecks, fearRating = 0) { + header(`SCENE ${sceneNum} ▸ ${title} [${type.toUpperCase()}]`); + log(''); + log(grey(` "${description}"`)); + log(''); + + log(cyan(` GM reveals scene to all players.`)); + log(''); + + // Fear tests first if applicable + if (fearRating > 0) { + subheader(`☠ FEAR RATING ${fearRating} — Willpower Tests`); + for (const p of Object.values(PLAYERS)) { + fearTest(p, fearRating, title); + } + log(''); + } + + // Skill checks + subheader('📋 SKILL CHECKS'); + let sceneSuccesses = 0; + let sceneFailures = 0; + + for (const assignment of assignedChecks) { + const player = PLAYERS[assignment.player]; + if (!player || !isAlive(player)) { + log(` ${grey(assignment.player + ' is unable to act.')}`); + sceneFailures++; + continue; + } + const r = performCheck(player, assignment.check.name, assignment.stat, player[assignment.stat], assignment.modifier ?? assignment.check.modifier ?? 0); + if (r.success) { + sceneSuccesses++; + log(` ${green('Reward: ' + assignment.check.reward)}`); + if (r.dos >= 3) { + player.heroMoments.push(`${assignment.check.name} (${r.dos} DoS) in ${title}`); + } + } else { + sceneFailures++; + if (r.dof >= 3) { + const comp = complications[Math.floor(Math.random() * complications.length)]; + log(` ${red('⚡ Complication triggers:')} ${comp}`); + player.shameList.push(`Triggered complication in ${title}: ${comp.slice(0, 50)}`); + // Apply minor damage for hard complications + if (comp.toLowerCase().includes('damage') || comp.toLowerCase().includes('wound') || comp.toLowerCase().includes('feeder') || comp.toLowerCase().includes('corrosive')) { + const { net } = dealDamage(player, '1d5', 0); + if (net > 0) log(` ${red(player.name + ' takes ' + net + ' wounds → ' + player.wounds + '/' + player.maxWounds)}`); + } + } + } + } + + log(''); + const outcome = sceneSuccesses >= Math.ceil(assignedChecks.length / 2) ? 'SUCCESS' : 'PARTIAL'; + log(outcome === 'SUCCESS' + ? bGreen(` ✅ Scene outcome: ${outcome} (${sceneSuccesses}/${assignedChecks.length} checks passed)`) + : bYellow(` ⚠ Scene outcome: ${outcome} (${sceneSuccesses}/${assignedChecks.length} checks passed)`)); + missionXp += sceneSuccesses * 50 + (outcome === 'SUCCESS' ? 100 : 0); + return { sceneSuccesses, sceneFailures }; +} + +function runCombatScene(sceneNum, title, description, checks, complications, enemies, fearRating = 0, maxRounds = 3) { + header(`SCENE ${sceneNum} ▸ ${title} [COMBAT]`); + log(''); + log(grey(` "${description}"`)); + log(''); + + log(cyan(` GM reveals scene. ${fearRating > 0 ? `Fear Rating ${fearRating} is active.` : ''}`)); + log(''); + + if (fearRating > 0) { + subheader(`☠ FEAR RATING ${fearRating} — Willpower Tests`); + for (const p of Object.values(PLAYERS)) { + fearTest(p, fearRating, title); + } + log(''); + } + + // Clone enemy pool + const activeEnemies = enemies.map(e => ({ ...e, wounds: e.wounds })); + + let round = 1; + let sceneSuccesses = 0; + let sceneFailures = 0; + + while (round <= maxRounds && activeEnemies.some(e => e.wounds > 0)) { + const alive = Object.values(PLAYERS).filter(isAlive); + if (alive.length === 0) { log(bRed(' ALL BROTHERS ARE DOWN. MISSION COMPROMISED.')); break; } + const result = combatRound(round, alive, activeEnemies.filter(e => e.wounds > 0), title, checks); + sceneSuccesses += result.sceneSuccesses; + sceneFailures += result.sceneFailures; + round++; + } + + const remainingEnemies = activeEnemies.filter(e => e.wounds > 0); + log(''); + if (remainingEnemies.length === 0) { + log(bGreen(` ✅ All enemies eliminated. Scene cleared!`)); + missionXp += 200; + } else { + log(bYellow(` ⚠ ${remainingEnemies.length} enemies remain — squad withdraws under fire.`)); + missionXp += 75; + } + + // Post-combat condition clear (rest) + for (const p of Object.values(PLAYERS)) { + if (p.conditions.includes('Fatigued') && Math.random() < 0.5) removeCondition(p, 'Fatigued'); + } + + return { sceneSuccesses, sceneFailures }; +} + +// ─── MISSION ───────────────────────────────────────────────────────────────── + +console.clear(); +log(''); +header('THE HUNT FOR FABIUS BILE — PLAYTHROUGH SIMULATION #2'); +log(grey(' GM: Watch-Captain Hestus | Kill-team: anders, christoffer, claes, phillip')); +log(grey(' Date: ' + new Date().toLocaleDateString('en-GB', { dateStyle: 'full' }))); +log(''); +log(' Kill-team composition:'); +log(` • ${bold('anders')} Blood Angels Devastator WS:60 BS:60 Wp:60 — Heavy Bolter`); +log(` • ${bold('christoffer')} Ultramarines Chaplain WS:75 BS:40 Wp:85 — Crozius Arcanum`); +log(` • ${bold('claes')} Ultramarines Tactical WS:70 BS:60 Wp:60 — Bolter + Power Fist`); +log(` • ${bold('phillip')} Ultramarines Tactical WS:60 BS:60 Wp:60 — Bolter + Chainsword`); +log(''); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 1 — Mission Brief [intro] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(1, + 'Mission Brief', 'intro', + 'Watch-Captain Hestus projects the kill-zone in cold blue lumen: a quarantined research world, a false distress call, and a gene-lab signature matching the works of Fabius Bile.', + [], // will use assignedChecks + [ + 'Orbital augurs show movement beneath the landing zone.', + 'The distress beacon repeats in a voice pattern copied from an executed Magos.', + ], + [ + { player: 'christoffer', check: { name: 'Analyse the briefing slate', reward: 'Identify signs of illegal gene-craft and likely sample storage.' }, stat: 'Int', modifier: 0 }, + { player: 'claes', check: { name: 'Plan the insertion', reward: 'Gain a +10 situational edge on the first scene check.' }, stat: 'Int', modifier: 10 }, + { player: 'anders', check: { name: 'Orbital threat assessment', reward: 'Note the bio-spore density for the insertion corridor.' }, stat: 'Per', modifier: 0 }, + ], + 0 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 2 — Landing Goes Wrong [combat] Fear 2 +// ───────────────────────────────────────────────────────────────────────────── +runCombatScene(2, + 'Landing Goes Wrong', + 'The Thunderhawk punches through ash cloud and bio-spore turbulence. The moment pods touch down, Tyranid forms erupt from the soil. Gaunts swarm the beacon.', + [ + { name: 'Spot the tunnel breach', stat: 'Per', modifier: 0 }, + { name: 'Hold formation under spore rain', stat: 'Ag', modifier: -10 }, + { name: 'Coordinate overlapping fire', stat: 'Fel', modifier: 0 }, + ], + [ + 'A natural 90+ means the landing beacon takes damage.', + 'Failed check lets the horde drag the fight into bad footing.', + ], + [ + { name: 'Hormagaunt', wounds: 8, bs: 45, ws: 50, ap: 0, damage: '1d5+3', agBonus: 5 }, + { name: 'Hormagaunt', wounds: 8, bs: 45, ws: 50, ap: 0, damage: '1d5+3', agBonus: 5 }, + { name: 'Termagant', wounds: 6, bs: 50, ws: 40, ap: 0, damage: '1d5+2', agBonus: 4 }, + { name: 'Termagant', wounds: 6, bs: 50, ws: 40, ap: 0, damage: '1d5+2', agBonus: 4 }, + ], + 2, // fear rating + 4 // max rounds +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 3 — Guard Survivors [social] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(3, + 'Guard Survivors', 'social', + 'A pocket of exhausted Guardsmen holds a barricaded manufactorum shrine. Their voices are strained, their eyes hollowed. Some have seen things that broke their faith.', + [], + [ + 'If the squad threatens the Guardsmen, a witness destroys useful evidence.', + 'The vox briefly repeats one Battle-Brother\'s private oath in an unknown voice.', + ], + [ + { player: 'christoffer', check: { name: 'Rally the survivors', reward: 'The Guardsmen provide a route marker and demolition charges.' }, stat: 'Fel', modifier: 10 }, + { player: 'phillip', check: { name: 'Read the compromised witness', reward: 'Notice xenos memory implant before it speaks.' }, stat: 'Per', modifier: -10 }, + { player: 'claes', check: { name: 'Purge vox contamination', reward: 'Recover a clean fragment of the distress log.' }, stat: 'Int', modifier: 0 }, + { player: 'anders', check: { name: 'Secure the perimeter', reward: 'No enemy scouts report the position.' }, stat: 'Per', modifier: 0 }, + ], + 0 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 4 — Supply Point [restock] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(4, + 'Supply Point', 'restock', + 'The supply point is a half-collapsed Munitorum depot. Ammunition crates lie open, but the air smells wrong — organic, wet, like the inside of something living.', + [], + [ + 'Opening the wrong crate releases feeder organisms.', + 'Taking too long lets spore growth reach the promethium lines.', + ], + [ + { player: 'anders', check: { name: 'Inventory usable supplies', reward: 'Find one useful supply cache.' }, stat: 'Int', modifier: 10 }, + { player: 'phillip', check: { name: 'Detect bio-contamination', reward: 'Avoid tainted supplies and identify the strain marker.' }, stat: 'Int', modifier: 0 }, + { player: 'claes', check: { name: 'Rig a demolition fallback', reward: 'Create a one-use environmental weapon.' }, stat: 'Int', modifier: -10 }, + ], + 0 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 5 — Research Station Investigation [investigation] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(5, + 'Research Station Investigation', 'investigation', + 'The research station is quiet except for the wet click of automated sample arms. Every screen displays the same rotating helix. The ceiling moves. Something breathes in the vents.', + [], + [ + 'Each failed investigation roll advances the station purge clock.', + 'The cogitator speaks with overlapping voices, one of them human.', + ], + [ + { player: 'christoffer', check: { name: 'Break the data quarantine', reward: 'Recover vault map and sample manifest.' }, stat: 'Int', modifier: -10 }, + { player: 'claes', check: { name: 'Interpret mutation records', reward: 'Learn what Tyranid strain is being guided by outside intelligence.' }, stat: 'Int', modifier: 0 }, + { player: 'anders', check: { name: 'Search the specimen galleries', reward: 'Find the ambush before it unfolds.' }, stat: 'Per', modifier: 0 }, + { player: 'phillip', check: { name: 'Track the synapse signature', reward: 'Locate the directing intelligence\'s relay node.' }, stat: 'Per', modifier: -10 }, + ], + 0 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 6 — Horde: Tyranid Swarm [combat] Fear 2 +// ───────────────────────────────────────────────────────────────────────────── +runCombatScene(6, + 'Horde — Tyranid Swarm', + 'The lower transit artery floods with Tyranid organisms. They do not attack like creatures. They attack like the ocean. A tide of chitin, claw, and alien hunger, directed by a distant will.', + [ + { name: 'Cut a path through the horde', stat: 'BS', modifier: 10 }, + { name: 'Counter the flank surge', stat: 'Int', modifier: 0 }, + { name: 'Stand against the press', stat: 'S', modifier: -10 }, + ], + [ + 'A failed check lets the horde drag the fight into bad footing.', + 'Using heavy fire draws synapse attention.', + ], + [ + { name: 'Horde-Gaunt α', wounds: 20, bs: 45, ws: 55, ap: 0, damage: '1d5+3', agBonus: 5 }, + { name: 'Horde-Gaunt β', wounds: 20, bs: 45, ws: 55, ap: 0, damage: '1d5+3', agBonus: 5 }, + { name: 'Warrior', wounds: 18, bs: 50, ws: 60, ap: 2, damage: '1d10+4', agBonus: 4 }, + ], + 2, // fear rating + 4 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 7 — Ambush at the Station [combat] Fear 3 +// ───────────────────────────────────────────────────────────────────────────── +runCombatScene(7, + 'Ambush at the Station', + 'Past the horde, the station opens into a surgical amphitheatre. The floor is slick with biological lubricant. Lictor-forms drop from the ceiling without sound. The lights strobe.', + [ + { name: 'React to the ceiling attack', stat: 'Per', modifier: -10 }, + { name: 'Secure the sample vessel', stat: 'Ag', modifier: 0 }, + { name: 'Read the ambush pattern', stat: 'Int', modifier: 0 }, + ], + [ + 'The lights strobe hard enough to foul range judgment (–10 BS).', + 'Blast weapons risk destroying evidence.', + ], + [ + { name: 'Lictor', wounds: 22, bs: 55, ws: 75, ap: 3, damage: '1d10+6', agBonus: 6 }, + { name: 'Lictor', wounds: 22, bs: 55, ws: 75, ap: 3, damage: '1d10+6', agBonus: 6 }, + { name: 'Genestealer', wounds: 16, bs: 45, ws: 70, ap: 3, damage: '1d10+5', agBonus: 6 }, + ], + 3, // fear rating + 4 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 8 — Synapse Connection [puzzle] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(8, + 'Synapse Connection', 'puzzle', + 'The synapse chamber is not a room so much as a wound in reality. Cables, nerves, and biotech fuse in the walls. The relay hums with alien thought. Every mind in the room feels it reaching in.', + [], + [ + 'A failed Willpower roll gives the GM license to present one false tactical detail.', + 'Destroying the relay immediately loses information about Bile\'s route.', + ], + [ + { player: 'christoffer', check: { name: 'Resist alien memory bleed', reward: 'Keep control and avoid false orders.' }, stat: 'Wp', modifier: -10 }, + { player: 'phillip', check: { name: 'Resist alien memory bleed', reward: 'Keep control and avoid false orders.' }, stat: 'Wp', modifier: -10 }, + { player: 'anders', check: { name: 'Resist alien memory bleed', reward: 'Keep control and avoid false orders.' }, stat: 'Wp', modifier: -10 }, + { player: 'claes', check: { name: 'Resist alien memory bleed', reward: 'Keep control and avoid false orders.' }, stat: 'Wp', modifier: -10 }, + { player: 'christoffer', check: { name: 'Trace the relay path', reward: 'Locate the intelligence directing the brood.' }, stat: 'Int', modifier: 0 }, + { player: 'claes', check: { name: 'Sever the relay safely', reward: 'Disable connection without psychic backlash.' }, stat: 'Int', modifier: -20 }, + ], + 1 // mild fear from alien presence +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 9 — Boss Fight: Neurothrope [combat] Fear 4 +// ───────────────────────────────────────────────────────────────────────────── +runCombatScene(9, + 'Boss Fight — Neurothrope', + 'The inner vault opens on a floating synaptic organism suspended in amniotic light. It turns toward the kill-team and something in its gaze makes the air hurt. The vault purge clock is already counting.', + [ + { name: 'Weather psychic pressure', stat: 'Wp', modifier: -20 }, + { name: 'Target exposed nerve clusters', stat: 'BS', modifier: -10 }, + { name: 'Keep the vault systems alive', stat: 'Int', modifier: -10 }, + ], + [ + 'At the end of each round the vault purge advances unless checked.', + 'Any Brother failing Willpower hears a perfect imitation of their squad commander.', + ], + [ + { name: 'Neurothrope', wounds: 35, bs: 60, ws: 50, ap: 4, damage: '1d10+8', agBonus: 4 }, + { name: 'Zoanthrope', wounds: 20, bs: 65, ws: 45, ap: 3, damage: '1d10+6', agBonus: 3 }, + ], + 4, // Fear 4 — hardest + 5 +); + +// ───────────────────────────────────────────────────────────────────────────── +// SCENE 10 — Empty Laboratory [epilogue] +// ───────────────────────────────────────────────────────────────────────────── +runSkillScene(10, + 'Empty Laboratory', 'epilogue', + 'The laboratory is empty when the last organism dies. Its primary vault has been wiped but the secondary archive still breathes. A vial rests on the central dais — Bile\'s work, contained but alive.', + [], + [ + 'The vial may be bait, evidence, or both.', + 'The last vox burst contains coordinates outside the declared warzone.', + ], + [ + { player: 'christoffer', check: { name: 'Secure unstable evidence', reward: 'Preserve the vial without contamination.' }, stat: 'Int', modifier: 0 }, + { player: 'claes', check: { name: 'Decode the helix marker', reward: 'Identify the next lead in the hunt for Bile.' }, stat: 'Int', modifier: -10 }, + { player: 'anders', check: { name: 'Extract the secondary archive', reward: 'Download the gene-craft formula index.' }, stat: 'Int', modifier: 0 }, + { player: 'phillip', check: { name: 'Deliver final judgement', reward: 'Stabilise loyal survivors and close the operation cleanly.' }, stat: 'Fel', modifier: 0 }, + ], + 0 +); + +// ─── MISSION DEBRIEF ───────────────────────────────────────────────────────── +header('MISSION DEBRIEF — OPERATION COMPLETE'); + +const allPlayers = Object.values(PLAYERS); +const totalSuccesses = allPlayers.reduce((s, p) => s + p.successCount, 0); +const totalFails = allPlayers.reduce((s, p) => s + p.failCount, 0); +const totalRolls = totalSuccesses + totalFails; +const missionSuccess = allPlayers.filter(isAlive).length >= 2 && missionXp >= 800; + +log(''); +log(` ${bold('Mission Result:')} ${missionSuccess ? bGreen('✅ VICTORY — EMPEROR PROTECTS') : bRed('❌ PARTIAL FAILURE — THE HUNT CONTINUES')}`); +log(` ${bold('Total XP earned:')} ${bYellow(missionXp)} XP`); +log(` ${bold('Combat rounds:')} ${totalRounds}`); +log(` ${bold('Rolls cast:')} ${totalRolls} (${green(totalSuccesses + ' successes')}, ${red(totalFails + ' failures')})`); +log(` ${bold('Success rate:')} ${Math.round(totalSuccesses / Math.max(1, totalRolls) * 100)}%`); +log(''); + +// Per-player report card +subheader('INDIVIDUAL BATTLE-BROTHER REPORT CARDS'); +for (const p of allPlayers) { + const rolls = p.successCount + p.failCount; + const pct = Math.round(p.successCount / Math.max(1, rolls) * 100); + const status= isAlive(p) ? green(`ALIVE (${p.wounds}/${p.maxWounds} wounds)`) : bRed('DOWN'); + const grade = pct >= 70 ? bGreen('A') : pct >= 55 ? yellow('B') : pct >= 40 ? yellow('C') : bRed('D'); + + log(''); + log(` ${bold(p.name.toUpperCase())} ${grey(p.chapter + ' ' + p.spec)}`); + log(` Status: ${status} | Fate remaining: ${p.fate}/${p.maxFate} | Fate spends: ${p.fateSpends}`); + log(` Rolls: ${rolls} (${green(p.successCount + '✅')}, ${red(p.failCount + '❌')}, ${pct}%) — Grade: ${grade}`); + log(` Conditions at end: ${p.conditions.length ? yellow(p.conditions.join(', ')) : green('None')}`); + if (p.heroMoments.length) { + log(` ${bGreen('★ HERO MOMENTS:')}`); + for (const h of p.heroMoments) log(` • ${h}`); + } + if (p.shameList.length) { + log(` ${red('✗ SHAME LIST:')}`); + for (const s of p.shameList) log(` • ${s}`); + } +} + +// Roll feed summary +log(''); +subheader('FULL ROLL FEED'); +log(grey(' (as visible to GM in roll feed panel)')); +log(''); +for (const entry of GM.rollFeed) { + const icon = entry.success ? green('✅') : red('❌'); + const dos = entry.success ? cyan(` ${entry.dos}DoS`) : ''; + const dof = !entry.success ? red(` ${entry.dof}DoF`) : ''; + log(` ${icon} ${bold(entry.player.padEnd(12))} ${entry.checkName.padEnd(40)} ${entry.result}/${entry.effective}${dos}${dof}`); +} + +// Issues to log for next iteration +log(''); +subheader('NEW FINDINGS FOR NEXT SESSION'); +const findings = []; + +// Check if any player ran out of fate with catastrophic rolls +const brokenPlayers = allPlayers.filter(p => !isAlive(p)); +if (brokenPlayers.length > 0) { + findings.push(`${brokenPlayers.map(p=>p.name).join(', ')} went down — consider Toughness-based damage soak display`); +} +const heroPlayers = allPlayers.filter(p => p.heroMoments.length >= 2); +if (heroPlayers.length > 0) { + findings.push(`${heroPlayers.map(p=>p.name).join(', ')} had notable hero moments — commendation mechanic could reward this`); +} +if (totalRounds >= 18) { + findings.push('Combat ran long — enemy count or wounds may need tuning for 4-player group'); +} +if (allPlayers.every(p => p.fateSpends === 0)) { + findings.push('No Fate points spent — difficulty may need to increase, or Fate spend button visibility is insufficient'); +} +if (allPlayers.some(p => p.shameList.length >= 3)) { + findings.push('One or more players struggled significantly — check if stat spread needs review'); +} +if (findings.length === 0) { + findings.push('Run was clean — no critical new issues found'); +} +for (const f of findings) log(` ⚡ ${f}`); + +log(''); +sep('═'); +log(bold(bCyan(' HUNT FOR FABIUS BILE — SIMULATION #2 COMPLETE'))); +sep('═'); +log('');