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:
793
src/tests/playthroughRun2.js
Normal file
793
src/tests/playthroughRun2.js
Normal 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('');
|
||||
Reference in New Issue
Block a user