- Seeded procedural runs (survivors, locations, weather, events) - 100-day campaign with 5 phases, scripted story beats, Day-100 finale - 8 endings incl. hidden 'First Light Again' - Data-driven events (people/world/story packs) with callback chains - Relationships, memories, grief, karma/hope systems - Tactical encounters (fight/hide/run/negotiate) with honest odds - 8 upgradeable camp buildings, 13 location templates - Canvas parallax scenes, procedural SVG portraits, weather FX - Synthesized adaptive audio (WebAudio, no assets) - Save slots + autosave (localStorage), shareable seeds - Tests: headless multi-seed simulator + browser E2E (playwright)
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
/* 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<typeof collectDueEvents>[number], rng: ReturnType<typeof createRng>): 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<string, number> = {};
|
|
|
|
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<string, number> = {};
|
|
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();
|