/* Headless auto-play: runs full 100-day campaigns across many seeds to * catch crashes, verify determinism, and sanity-check balance. * * npm run sim -> 12 seeds * npm run sim -- --seeds=40 */ import { createNewGame } from '../src/engine/state'; import { createRng } from '../src/engine/rng'; import { apMaxOf, collectDueEvents, doBuild, doExplore, doHunt, doRepair, doRest, doScavenge, doTalk, doTravel, endDay, estimateOdds, resolveEncounter, runEventChoice, castFor, canBuild, } from '../src/engine/sim'; import { finalizeEnding } from '../src/engine/ending'; import { aliveOf } from '../src/engine/sim-core'; import type { EventChoiceReq, GameState } from '../src/engine/types'; let failures = 0; function fail(msg: string) { console.error('FAIL:', msg); failures++; } function reqMet(s: GameState, req?: EventChoiceReq): boolean { if (!req) return true; for (const k of Object.keys(req.res ?? {}) as (keyof typeof s.res)[]) { if (s.res[k] < (req.res![k] ?? 0)) return false; } if (req.flag && !s.flags[req.flag]) return false; if (req.noFlag && s.flags[req.noFlag]) return false; if (req.minFlag && (s.flags[req.minFlag.key] ?? 0) < req.minFlag.min) return false; if (req.aliveMin && aliveOf(s).length < req.aliveMin) return false; return true; } function pickChoice(s: GameState, ev: ReturnType[number], rng: ReturnType): number | null { const viable = ev.choices.map((c, i) => ({ c, i })).filter(x => reqMet(s, x.c.req)); if (!viable.length) return null; // heuristic: prefer later choices slightly (usually more interesting), random tiebreak return rng.chance(0.6) ? viable[viable.length - 1].i : rng.pick(viable).i; } function playSeed(seed: number, verbose = false): GameState { const s = createNewGame(seed); const ui = createRng(seed ^ 0xA11CE); // separate rng for bot decisions let dayGuard = 0; while (s.phase !== 'gameover' && dayGuard++ < 130) { // --- morning events --- const events = collectDueEvents(s); for (const ev of events) { const cast = castFor(s, ev, ui); const idx = pickChoice(s, ev, ui); if (idx === null) continue; try { runEventChoice(s, ev, idx, cast); } catch (e) { fail(`seed ${seed} day ${s.day} event ${ev.id} choice ${idx}: ${e}`); } // resolve any encounters triggered inside events conservatively while (s.pending.length) { const enc = s.pending[0]; const odds = estimateOdds(s, enc); const approach = odds.fight >= 55 ? 'fight' : (odds.hide ?? 0) >= 50 ? 'hide' : (odds.negotiate ?? 0) >= 45 ? 'negotiate' : 'fight'; try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); } } } if (s.day >= 100 && s.flags.finale_path) { finalizeEnding(s); break; } // --- plan actions --- const alive = aliveOf(s); if (!alive.length) break; const buildOrder = ['shelter', 'water_collector', 'storage', 'garden', 'medical', 'workshop', 'watchtower', 'generator']; const actionsLeft = () => Math.min(s.ap, apMaxOf(s)); while (actionsLeft() > 0 && aliveOf(s).length > 0) { const acted = (() => { try { // finish building priorities first for (const b of buildOrder.slice(0, 4)) { const { affordable } = canBuild(s, b); if (affordable && s.ap > 0) { doBuild(s, b); return true; } } const gm = aliveOf(s); const avgMorale = gm.reduce((t, x) => t + x.morale, 0) / Math.max(1, gm.length); const known = s.locations.filter(l => l.discovered && !l.cleared); if (known.length < 3 || (ui.f() < 0.25 && s.day < 60)) { const ex = gm[0]; doExplore(s, ex.id); return true; } if (avgMorale < 32 && ui.f() < 0.5) { doRest(s); return true; } if (s.camp.integrity < 45 && s.res.materials >= 2 && ui.f() < 0.5) { doRepair(s); return true; } // scavenge: prefer special-bearing sites sometimes, else richest known const specialSite = known.filter(l => l.specialId && !l.specialFound) .sort((a, b) => b.tier - a.tier)[0]; const site = (specialSite && ui.f() < 0.5) ? specialSite : known.sort((a, b) => b.lootPool - a.lootPool)[0]; if (site && !(s.weather === 'storm' && site.tier >= 3)) { const party = gm.slice(0, Math.min(2, gm.length)).map(x => x.id); const r = doScavenge(s, site.uid, party); if (r.ok) return true; } if (ui.f() < 0.35) { doHunt(s, gm.slice(0, 2).map(x => x.id)); return true; } if (gm.length >= 2 && ui.f() < 0.3) { doTalk(s, gm[0].id, gm[1].id); return true; } // late-game: try travel for tier 3 loot if (s.day > 30 && ui.f() < 0.3) { const targets = s.locations.filter(l => l.tier === 3 && !l.cleared && l.discovered); if (targets.length) { doTravel(s, targets[0].uid, gm.slice(0, 2).map(x => x.id)); return true; } } // remaining builds for (const b of buildOrder.slice(4)) { const { affordable } = canBuild(s, b); if (affordable && s.ap > 0) { doBuild(s, b); return true; } } doRest(s); return true; } catch (e) { fail(`seed ${seed} day ${s.day} action: ${e}`); return false; } })(); if (!acted) break; // encounters mid-action while (s.pending.length) { const enc = s.pending[0]; const odds = estimateOdds(s, enc); const approach = (odds.hide ?? 0) >= 50 ? 'hide' : odds.fight >= 55 ? 'fight' : (odds.run ?? 0) >= 45 ? 'run' : (odds.negotiate ?? 0) >= 40 ? 'negotiate' : 'run'; try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); } } // events fired by scavenging void verbose; } // resolve leftover pending while (s.pending.length) { const enc = s.pending[0]; try { resolveEncounter(s, 'fight'); } catch (e) { fail(`pending encounter: ${e}`); } } // --- night --- try { const rep = endDay(s); if (rep.gameOver) { finalizeEnding(s); break; } } catch (e) { fail(`seed ${seed} endDay ${s.day}: ${e}`); break; } // sanity checks for (const resKey of Object.keys(s.res) as (keyof typeof s.res)[]) { if (!Number.isFinite(s.res[resKey])) fail(`NaN resource ${resKey} seed ${seed} day ${s.day}`); } for (const x of s.survivors) { if (!Number.isFinite(x.hp) || !Number.isFinite(x.morale) || !Number.isFinite(x.hunger)) { fail(`NaN vitals for ${x.name} seed ${seed} day ${s.day}`); } if ((x.morale < -0.001) || x.morale > 100.001) fail(`morale out of range ${x.name} ${x.morale}`); } if (aliveOf(s).length && s.day <= 100 && !anyPathPossible(s) && s.day > 95) { // soft warning only } } finalizeEnding(s); return s; } function anyPathPossible(_s: GameState): boolean { return true; } /* ---------------- runner ---------------- */ function summarize(seed: number, s: GameState) { const alive = aliveOf(s); const dead = s.survivors.filter(x => !x.alive && !x.gone); const left = s.survivors.filter(x => x.gone); for (const d of dead) { const key = (d.deathCause ?? '?').replace(/(fighting|by|the)\s+.*/i, m => m.slice(0, 28)); deathCauses[key] = (deathCauses[key] ?? 0) + 1; } console.log( `seed ${String(seed).padStart(8)} | end D${String(Math.min(s.day, 100)).padStart(3)} ` + `| ${String(alive.length).padStart(2)} alive, ${dead.length} dead, ${left.length} left ` + `| food ${String(Math.round(s.res.food)).padStart(3)} water ${String(Math.round(s.res.water)).padStart(3)} med ${String(Math.round(s.res.medicine)).padStart(2)} ` + `| shelter L${s.camp.buildings.shelter ?? 0} gen L${s.camp.buildings.generator ?? 0} tower L${s.camp.buildings.watchtower ?? 0} ` + `| radio ${(s.flags.radio_parts ?? 0)}/3 veh:${s.flags.vehicle ? 'Y' : 'n'} vault:${s.flags.seed_vault ? 'Y' : 'n'} ` + `| ENDING: ${s.endingId}`, ); } const deathCauses: Record = {}; function hashState(s: GameState): string { const { survivors, rel, res, flags, strFlags, camp, locations, rngCursor, seenEvents, stats, queuedEvents, endingId } = s; return JSON.stringify({ survivors: survivors.map(x => ({ i: x.id, hp: x.hp, hu: x.hunger, m: Math.round(x.morale), sk: x.skills, inj: x.injuries.map(j => [j.kind, j.severity, j.daysLeft]), dead: x.alive ? 0 : 1, gone: x.gone ? 1 : 0 })), rel, res, flags, strFlags, camp, locations: locations.map(l => ({ u: l.uid, r: l.risk, lp: l.lootPool, d: l.discovered ? 1 : 0, sp: l.specialFound ? 1 : 0 })), rngCursor, seenEvents, stats, q: queuedEvents.length, endingId, }); } async function main() { const argSeeds = process.argv.find(a => a.startsWith('--seeds=')); const n = argSeeds ? parseInt(argSeeds.split('=')[1], 10) : 12; console.log(`Running ${n} seeded campaigns…\n`); const endings: Record = {}; let totalDead = 0, totalAliveEnd = 0, finished100 = 0; for (let i = 0; i < n; i++) { const seed = (i * 2654435761) % 0xffffffff; const s = playSeed(seed); summarize(seed, s); endings[s.endingId ?? 'none'] = (endings[s.endingId ?? 'none'] ?? 0) + 1; totalDead += s.survivors.filter(x => !x.alive && !x.gone).length; totalAliveEnd += aliveOf(s).length; if (s.day >= 100 || s.endingId === 'silence') finished100++; } // determinism check console.log('\nDeterminism check…'); const detSeed = 123456789; const a = playSeed(detSeed); const b = playSeed(detSeed); if (hashState(a) !== hashState(b)) fail('determinism: same seed produced different states'); else console.log('determinism OK'); console.log('\n==== SUMMARY ===='); console.log('death causes:', Object.entries(deathCauses).sort((a, b) => b[1] - a[1])); console.log('endings:', endings); console.log(`avg dead/run: ${(totalDead / n).toFixed(2)}, avg survivors at end: ${(totalAliveEnd / n).toFixed(2)}, runs resolved: ${finished100}/${n}`); if (failures === 0) console.log('\nALL CHECKS PASSED'); else { console.log(`\n${failures} FAILURES`); process.exitCode = 1; } } main();