100 Days After — complete playable survival strategy game

- 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)
This commit is contained in:
2026-08-23 06:58:47 +00:00
commit 486201655b
36 changed files with 9989 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
/* 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();