test: add playthrough simulation #2 script (playthroughRun2.js)

Full standalone Node.js simulation of 'The Hunt for Fabius Bile'.
Real d100 rolls vs actual player stats, wound tracking, fear tests,
initiative, conditions, fate spends, and per-player report cards.
Run: node src/tests/playthroughRun2.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 00:55:42 +02:00
parent 02ddc2686b
commit a7721006ef

View File

@@ -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('');