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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
shots/
*.log
.vite/
+108
View File
@@ -0,0 +1,108 @@
# 100 DAYS AFTER
*A survival story in one hundred days.*
Lead a small band of survivors through a world ended by **Grayfall** — an engineered
blight that came down with the dust. Every day you have a handful of actions, a ledger
of hungry mouths, and a hundred ways to lose someone. What happens on Day 100 depends
on what you built, learned, earned — and who you buried.
![genre](https://img.shields.io/badge/genre-survival%20strategy%20%2F%20interactive%20story-e0784a)
![engine](https://img.shields.io/badge/built%20with-TypeScript%20%2B%20Canvas%20%2B%20WebAudio-5da9a1)
---
## Play
```bash
npm install
npm run dev # → http://localhost:5199
```
Production build: `npm run build` then `npm run preview`.
### Controls
- Everything is mouse-driven. Hover anything with a dotted underline for tooltips.
- Each day: spend limited **actions** (SCAVENGE / EXPLORE / HUNT / BUILD / TALK /
TRADE / REPAIR / REST / TRAVEL), then press **END DAY** and read the night ledger.
- Click survivors for their full sheet: skills, traits, relationships, memories.
- The MAP tab previews each location's illustrated scene; the JOURNAL keeps your story.
- Encounters show honest odds for Fight / Hide / Run / Negotiate. Estimates. Not promises.
## The shape of a run
| Days | Phase | What happens |
|------|-------|--------------|
| 120 | Early Survival | Scarcity, first strangers, learning the valley |
| 2150 | Expansion | New faces, bigger camp, the wide world opens |
| 5180 | Escalation | Raiders, herds of the Dust-Sick, hard choices |
| 8199 | Endgame | Radio fragments resolve; escape plans form |
| 100 | Finale | Convoy, boats, the last raid… or something nobody told you about |
**Eight endings**, including one hidden ending that requires three recovered radio
parts, a seed vault, a running generator, and enough hope to be dangerous.
Runs are seeded and fully reproducible — same seed, same world. Share seeds.
## Architecture
Clean separation of state, simulation, content and presentation:
```
src/
engine/ pure logic, zero DOM
types.ts all shared types (GameState is plain serializable data)
rng.ts seeded mulberry32 + cursor (exact reproducibility)
state.ts new-game generation (survivors, map, weather, stores)
sim-core.ts caps, morale floor, defense rating, relationship math
eventctx.ts the mutation API events are written against (+ death & grief)
sim.ts actions, tactical encounters, night resolution, story beats
ending.ts ending selection + epilogue data
save.ts slots + autosave (localStorage), versioned envelopes
content/ data-driven game content
survivors.ts names, occupations, traits, personalities, goals, weapons
locations.ts 13 location templates (loot tables, risk, specials)
buildings.ts 8 upgradeable structures
enemies.ts enemy archetypes
world.ts weather table, campaign phases, lore fragments
events/
people.ts strangers, traders, internal conflict, callback chains
world.ts storms, creatures, factions, discoveries
story.ts scripted beats (Day 2→99), Day-100 finale, all endings
ui/ presentation
portraits.ts procedural SVG character portraits with moods
scene.ts canvas parallax scenes, weather particles, camp rendering
ui.ts screens, HUD, modals, event/combat flow
audio/audio.ts synthesized wind/rain/thunder, adaptive music pads, SFX
scripts/
headless.ts auto-plays full campaigns across N seeds:
crash checks, determinism proof, balance histogram
smoke.mjs real-browser test: boot → play days → save → reload → load
```
Adding content never touches core systems: new events are entries in `content/events/*`
(a title, conditions, choices with requirements, and outcome functions using the `ctx`
helper API); new locations/buildings/enemies are plain data records.
## Emergent storytelling systems
- **Memory:** every survivor logs what happened *to them* ("Skipped supper so others could eat").
- **Grief:** deaths ripple through relationships; close friends spiral, confront you,
and remember on Day 99.
- **Callback chains:** rob the caravan and it returns; spare the stranger and he
remembers; feed the family and a package appears at the fence weeks later.
- **Feeding priority:** food shortfalls feed the hungriest first — whoever's last in
line develops opinions about leadership.
- **Karma & hope** quietly steer strangers' returns, tribute demands, and endings.
## Testing
```bash
npm run sim # 12 seeded campaigns, headless: crashes/determinism/balance
npm run sim -- --seeds=40
npm run smoke # browser E2E through the real UI (needs the dev server)
npm run build # typecheck + production bundle
```
*Built as a vertical-slice-first project, expanded to the full 100-day campaign.*
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="100 Days After — a survival story in one hundred days. Lead a band of survivors through the ruins of the Grayfall." />
<title>100 Days After</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%230b0e13'/><text x='16' y='22' font-size='14' text-anchor='middle' fill='%23e0784a' font-family='Georgia'>100</text></svg>" />
</head>
<body>
<div id="app"></div>
<div id="toasts"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1598
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "100-days-after",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5199",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 5199",
"sim": "tsx scripts/headless.ts",
"smoke": "node scripts/smoke.mjs",
"shots": "node scripts/shots.mjs"
},
"devDependencies": {
"@types/node": "^26.2.0",
"playwright-core": "^1.62.1",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+72
View File
@@ -0,0 +1,72 @@
import { chromium } from 'playwright-core';
// build a Day-100-ready save with node + tsx first
const { execSync } = await import('node:child_process');
const stateJson = execSync(`npx tsx -e "
import { createNewGame } from './src/engine/state';
const s = createNewGame(2024);
s.day = 99; s.ap = 3;
s.flags.evacPrep = 1; s.flags.heardTruth = 1; s.flags.militaryRep = 3;
s.res.food = 40; s.res.water = 40; s.res.medicine = 6;
console.log(JSON.stringify({ v: s.version, ts: Date.now(), state: s }));
"`, { encoding: 'utf8', cwd: '/root/100days' }).trim();
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const ctx = await browser.newContext({ viewport:{width:1440,height:900} });
await ctx.addInitScript((save) => {
localStorage.setItem('d100_save_auto', save);
}, stateJson);
const page = await ctx.newPage();
page.setDefaultTimeout(9000);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.click('text=CONTINUE');
await page.waitForSelector('.game-layout');
console.log('loaded day:', await page.evaluate(()=>document.querySelector('#hud-day')?.textContent));
// end days 99 -> 100 -> finale
for (let i = 0; i < 10; i++) {
// drain modals
for (let k = 0; k < 12; k++) {
const r = await page.evaluate(() => {
const ov = document.querySelectorAll('.modal-root .overlay');
const last = ov[ov.length - 1];
if (!last) return { none: true };
const btns = [...last.querySelectorAll('button')].filter(b => !b.disabled && b.offsetParent !== null);
if (/DAY 100/i.test(last.querySelector('.modal-head h2')?.textContent ?? '')) {
const muster = btns.find(b => /convoy|muster/i.test(b.textContent));
const any = muster ?? btns[0];
any.click();
return { finale: true, clicked: any.textContent.slice(0, 30) };
}
const t = btns.find(b => /CONTINUE|STEEL/i.test(b.textContent))
|| btns.find(b => b.classList.contains('ev-choice') && !b.classList.contains('locked'))
|| btns.find(b => b.classList.contains('choice'));
if (!t) return { stuck: true };
t.click();
return { clicked: true };
});
if (r.none) break;
if (r.finale) { console.log('FINALE choice:', r.clicked); continue; }
if (r.stuck) { console.log('STUCK on modal'); break; }
await page.waitForTimeout(300);
}
const ended = await page.evaluate(() => !!document.querySelector('.ending-modal'));
if (ended) break;
const btn = page.locator('#btn-endday');
if (!(await btn.isEnabled())) break;
await btn.click();
await page.waitForTimeout(500);
}
await page.waitForTimeout(800);
const ending = await page.evaluate(() => {
const el = document.querySelector('.ending-title');
return el ? el.textContent : null;
});
await page.screenshot({ path: 'shots/ending.png' });
console.log('ENDING SHOWN:', ending);
console.log(errors.length ? 'PAGE ERRORS: ' + errors.join(' | ') : 'no page errors');
await browser.close();
if (!ending || errors.length) process.exit(1);
console.log('FINALE TEST PASSED');
+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();
+58
View File
@@ -0,0 +1,58 @@
import { chromium } from 'playwright-core';
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport:{width:1440,height:900} });
page.setDefaultTimeout(9000);
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.waitForTimeout(1200);
await page.screenshot({ path: 'shots/title.png' });
await page.click('text=NEW RUN');
await page.fill('.seed-input','31337');
await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout');
await page.waitForTimeout(1500);
await page.screenshot({ path: 'shots/game.png' });
// survivor detail
await page.locator('.surv-card:not(.dead)').first().click();
await page.waitForTimeout(400);
await page.screenshot({ path: 'shots/survivor.png' });
await page.click('.modal-x');
// build menu
await page.click('.action:has-text("BUILD")');
await page.waitForTimeout(400);
await page.screenshot({ path: 'shots/build.png' });
await page.keyboard.press('Escape').catch(()=>{});
await page.evaluate(() => document.querySelector('.modal-x')?.click());
await page.waitForTimeout(300);
// force an encounter via hunt a few times for the shot; else map hover
await page.click('[data-tab="map"]');
await page.waitForTimeout(200);
await page.locator('.map-entry').nth(2).hover();
await page.waitForTimeout(800);
await page.screenshot({ path: 'shots/map-preview.png' });
// end day -> night report shot
await page.click('#btn-endday');
await page.waitForTimeout(600);
for (let i=0;i<6;i++){
const res = await page.evaluate(()=>{
const ov=document.querySelectorAll('.modal-root .overlay'); const last=ov[ov.length-1];
if(!last) return {done:true};
const btns=[...last.querySelectorAll('button')].filter(b=>!b.disabled&&b.offsetParent!==null);
const t=btns.find(b=>/CONTINUE|STEEL/i.test(b.textContent))||btns.find(b=>b.classList.contains('ev-choice')&&!b.classList.contains('locked'))||btns.find(b=>b.classList.contains('choice'));
if(!t) return {done:true};
const isEvent = !!t.closest('.event-modal, .ev-choice');
t.click();
return {done:false, isEvent};
});
if (res.done) break;
if (i===0) await page.waitForTimeout(250);
await page.screenshot({ path: 'shots/modal.png' }).catch(()=>{});
await page.waitForTimeout(380);
}
await browser.close();
console.log('shots done');
+136
View File
@@ -0,0 +1,136 @@
/* Browser smoke test: boots the game, starts a run, plays a few days
* through the real UI, and fails on any console error or page crash.
*
* node scripts/smoke.mjs [url]
*/
import { chromium } from 'playwright-core';
const url = process.argv[2] ?? 'http://127.0.0.1:5199';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(9000);
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push('console: ' + msg.text());
});
page.on('pageerror', err => errors.push('pageerror: ' + err.message));
const step = async (name, fn) => {
try { await fn(); console.log('ok ', name); }
catch (e) { console.log('FAIL', name, '-', e.message.split('\n')[0]); errors.push(name + ': ' + e.message); }
};
await step('load page', async () => {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-screen', { timeout: 10000 });
});
await step('new run dialog', async () => {
await page.click('text=NEW RUN');
await page.waitForSelector('.seed-input');
});
await step('start run', async () => {
await page.fill('.seed-input', '777001');
await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout', { timeout: 10000 });
});
await step('HUD renders', async () => {
await page.waitForFunction(() => document.querySelector('#hud-day')?.textContent?.includes('DAY'));
const resCount = await page.locator('.res-chip').count();
if (resCount !== 7) throw new Error('expected 7 resource chips, got ' + resCount);
});
await step('survivor list renders', async () => {
const n = await page.locator('.surv-card:not(.dead)').count();
if (n < 3 || n > 4) throw new Error('unexpected survivor count ' + n);
await page.locator('.surv-card:not(.dead)').first().click();
await page.waitForSelector('.sd-body');
await page.click('.modal-x');
});
await step('map tab renders', async () => {
await page.click('[data-tab="map"]');
const entries = await page.locator('.map-entry').count();
if (entries < 2) throw new Error('map too empty');
// hover preview shouldn't crash
await page.locator('.map-entry').nth(1).hover();
await page.click('[data-tab="log"]');
});
await step('scavenge action flow', async () => {
await page.click('.action:has-text("SCAVENGE")');
await page.waitForSelector('.site-grid .site-card');
await page.locator('.site-card').first().click();
const rows = page.locator('.party-check .check-row');
await rows.first().locator('input').check();
if (await rows.count() > 1) await rows.nth(1).locator('input').check();
await page.click('text=HEAD OUT');
await page.waitForSelector('.report, .enc-modal', { timeout: 8000 });
// resolve possible encounter
for (let i = 0; i < 6; i++) {
const enc = page.locator('.enc-modal .btn.choice');
if (await enc.count()) { await enc.first().click(); await page.waitForTimeout(300); continue; }
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
if (await cont.count()) { await cont.first().click(); break; }
break;
}
await page.waitForTimeout(400);
});
// play several days via END DAY until an event modal appears and handle choices
for (let d = 0; d < 8; d++) {
await step(`day cycle ${d + 1}`, async () => {
// close any open report/event by clicking CONTINUE / first choice repeatedly
for (let i = 0; i < 10; i++) {
const choice = page.locator('.ev-choice:not(.locked)');
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
const encBtns = page.locator('.enc-modal .btn.choice');
if (await encBtns.count()) { await encBtns.first().click(); await page.waitForTimeout(250); continue; }
if (await choice.count()) { await choice.first().click(); await page.waitForTimeout(250); continue; }
if (await cont.count()) { await cont.first().click(); await page.waitForTimeout(250); continue; }
break;
}
const endBtn = page.locator('#btn-endday');
if (await endBtn.isEnabled()) await endBtn.click();
await page.waitForTimeout(500);
for (let i = 0; i < 12; i++) {
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
const encBtns = page.locator('.enc-modal .btn.choice');
const evChoice = page.locator('.ev-choice:not(.locked)');
if (await encBtns.count()) { await encBtns.first().click(); await page.waitForTimeout(250); continue; }
if (await cont.count()) { await cont.first().click(); await page.waitForTimeout(350); continue; }
if (await evChoice.count()) { await evChoice.first().click(); await page.waitForTimeout(250); continue; }
break;
}
});
}
await step('save to slot', async () => {
await page.click('#btn-menu');
await page.click('text=SAVE SLOT 1');
await page.waitForTimeout(300);
});
await step('reload + continue via load modal', async () => {
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.click('text=LOAD RUN');
await page.waitForSelector('.save-row');
await page.locator('.save-row .btn:has-text("LOAD")').first().click();
await page.waitForSelector('.game-layout', { timeout: 8000 });
});
console.log('\n---- RESULT ----');
if (errors.length) {
console.log(errors.length + ' ERRORS:');
for (const e of errors.slice(0, 20)) console.log(' -', e);
process.exitCode = 1;
} else {
console.log('SMOKE TEST PASSED — no console/page errors across boot, play, save, load.');
}
await browser.close();
+54
View File
@@ -0,0 +1,54 @@
import { chromium } from 'playwright-core';
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport:{width:1440,height:900} });
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.waitForTimeout(1500);
function analyzeCanvas(sel){
return page.evaluate((sel)=>{
const c=document.querySelector(sel);
const g=c.getContext('2d');
const d=g.getImageData(0,0,c.width,c.height).data;
let sum=0,sum2=0,n=0; const buckets={};
for(let i=0;i<d.length;i+=4*97){
const v=(d[i]+d[i+1]+d[i+2])/3;
sum+=v;sum2+=v*v;n++;
const b=Math.floor(v/32); buckets[b]=(buckets[b]??0)+1;
}
const mean=sum/n, sd=Math.sqrt(Math.max(0,sum2/n-mean*mean));
return {w:c.width,h:c.height,mean:Math.round(mean),sd:Math.round(sd),buckets};
},sel);
}
console.log('TITLE canvas:', JSON.stringify(await analyzeCanvas('#title-canvas')));
// body font/bg sanity
console.log('body bg:', await page.evaluate(()=>getComputedStyle(document.body).backgroundColor));
console.log('logo font-size:', await page.evaluate(()=>getComputedStyle(document.querySelector('.logo')).fontSize));
await page.click('text=NEW RUN'); await page.fill('.seed-input','999'); await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout');
await page.waitForTimeout(1500);
console.log('GAME canvas:', JSON.stringify(await analyzeCanvas('#scene')));
// layout geometry: no zero-size majors, actionbar buttons count
const geo = await page.evaluate(()=>{
const r=s=>{const e=document.querySelector(s); if(!e) return null; const b=e.getBoundingClientRect(); return [Math.round(b.width),Math.round(b.height)];};
return {
scene:r('#scene'), left:r('.panel.left'), right:r('.panel.right'),
actions:[...document.querySelectorAll('.action')].length,
survCards:document.querySelectorAll('.surv-card').length,
resChips:document.querySelectorAll('.res-chip').length,
apPips:document.querySelectorAll('.ap-pip').length,
svgPorts:document.querySelectorAll('.surv-card svg').length,
};
});
console.log('geometry:',JSON.stringify(geo));
// portrait svg content sanity
const svgInfo = await page.evaluate(()=>{
const s=document.querySelector('.surv-card svg');
return s?{paths:s.querySelectorAll('path,ellipse,circle,rect').length,len:s.innerHTML.length}:null;
});
console.log('portrait:',JSON.stringify(svgInfo));
await browser.close();
console.log('VISUAL CHECK DONE');
+238
View File
@@ -0,0 +1,238 @@
/* Synthesized audio: layered ambience (wind/rain), adaptive generative
music pads per mood, and UI/combat SFX. No external assets. */
type Mood = 'title' | 'explore' | 'tense' | 'sad' | 'hopeful';
const CHORDS: Record<Mood, number[][]> = {
// frequencies (Hz) — minor-leaning voicings, kept low and warm
title: [[73.4, 146.8, 220, 261.6], [87.3, 174.6, 261.6, 329.6], [65.4, 130.8, 196, 246.9], [73.4, 146.8, 220, 293.7]],
explore: [[73.4, 146.8, 220, 293.7], [98, 196, 246.9, 293.7], [82.4, 164.8, 246.9, 311.1], [73.4, 146.8, 220, 261.6]],
tense: [[69.3, 138.6, 207.7, 261.6], [73.4, 146.8, 207.7, 277.2]],
sad: [[87.3, 174.6, 261.6, 349.2], [82.4, 164.8, 220, 329.6]],
hopeful: [[116.5, 233, 349.2, 440], [98, 196, 293.7, 392], [110, 220, 329.6, 415.3]],
};
function makeNoiseBuffer(ctx: AudioContext, seconds = 2, brown = true): AudioBuffer {
const buf = ctx.createBuffer(1, ctx.sampleRate * seconds, ctx.sampleRate);
const d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < d.length; i++) {
const white = Math.random() * 2 - 1;
if (brown) { last = (last + 0.02 * white) / 1.02; d[i] = last * 3.2; }
else d[i] = white;
}
return buf;
}
export class GameAudio {
private ctx: AudioContext | null = null;
private master!: GainNode;
private ambBus!: GainNode;
private musBus!: GainNode;
private sfxBus!: GainNode;
private windGain!: GainNode;
private windFilter!: BiquadFilterNode;
private rainGain!: GainNode;
private noiseBuf!: AudioBuffer;
private mood: Mood = 'explore';
private chordIdx = 0;
private padTimer = 0;
private padOscs: OscillatorNode[] = [];
private padGain!: GainNode;
private thunderTimer = 0;
muted = false;
get ready() { return this.ctx !== null; }
init() {
if (this.ctx) return;
try {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
this.ctx = new AC();
const c = this.ctx;
this.master = c.createGain();
this.master.gain.value = this.muted ? 0 : 0.85;
this.master.connect(c.destination);
this.ambBus = c.createGain(); this.ambBus.gain.value = 1; this.ambBus.connect(this.master);
this.musBus = c.createGain(); this.musBus.gain.value = 0.55; this.musBus.connect(this.master);
this.sfxBus = c.createGain(); this.sfxBus.gain.value = 0.9; this.sfxBus.connect(this.master);
this.noiseBuf = makeNoiseBuffer(c, 3, true);
// wind: filtered brown noise with slow LFO
const windSrc = c.createBufferSource();
windSrc.buffer = this.noiseBuf; windSrc.loop = true;
this.windFilter = c.createBiquadFilter();
this.windFilter.type = 'bandpass'; this.windFilter.frequency.value = 320; this.windFilter.Q.value = 0.6;
this.windGain = c.createGain(); this.windGain.gain.value = 0.12;
const lfo = c.createOscillator(); lfo.frequency.value = 0.09;
const lfoGain = c.createGain(); lfoGain.gain.value = 90;
lfo.connect(lfoGain); lfoGain.connect(this.windFilter.frequency);
windSrc.connect(this.windFilter); this.windFilter.connect(this.windGain); this.windGain.connect(this.ambBus);
windSrc.start(); lfo.start();
// rain: white noise, high-passed hiss
const rainSrc = c.createBufferSource();
rainSrc.buffer = makeNoiseBuffer(c, 2, false); rainSrc.loop = true;
const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1400;
const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 6500;
this.rainGain = c.createGain(); this.rainGain.gain.value = 0;
rainSrc.connect(hp); hp.connect(lp); lp.connect(this.rainGain); this.rainGain.connect(this.ambBus);
rainSrc.start();
// music pad chain
this.padGain = c.createGain(); this.padGain.gain.value = 0.14;
const padFilter = c.createBiquadFilter(); padFilter.type = 'lowpass'; padFilter.frequency.value = 720;
this.padGain.connect(padFilter); padFilter.connect(this.musBus);
this.setAmbience('clear');
this.setMood('explore');
this.schedulePad();
} catch {
this.ctx = null;
}
}
resume() { this.ctx?.resume?.(); }
setMuted(m: boolean) {
this.muted = m;
if (this.ctx) this.master.gain.linearRampToValueAtTime(m ? 0 : 0.85, this.ctx.currentTime + 0.15);
try { localStorage.setItem('d100_mute', m ? '1' : '0'); } catch { /* ignore */ }
}
/** weather-driven ambience levels */
setAmbience(weather: string) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const wind = { clear: 0.1, rain: 0.16, storm: 0.3, fog: 0.07, cold: 0.2, heat: 0.05 }[weather] ?? 0.1;
const rain = { clear: 0, rain: 0.16, storm: 0.34, fog: 0, cold: 0, heat: 0 }[weather] ?? 0;
this.windGain.gain.linearRampToValueAtTime(wind, t + 1.2);
this.windFilter.frequency.linearRampToValueAtTime(weather === 'storm' ? 520 : 300, t + 1.2);
this.rainGain.gain.linearRampToValueAtTime(rain, t + 1.5);
if (weather === 'storm') this.scheduleThunder();
}
setMood(mood: Mood) {
if (this.mood === mood || !this.ctx) return;
this.mood = mood;
this.chordIdx = 0;
this.playChord(CHORDS[mood][0]);
}
/* ------------ internals ------------ */
private schedulePad() {
if (!this.ctx) return;
const chords = CHORDS[this.mood];
this.padTimer = window.setInterval(() => {
if (!this.ctx || document.hidden) return;
this.chordIdx = (this.chordIdx + 1) % chords.length;
this.playChord(chords[this.chordIdx]);
// sparse plucked note above the pad
if (Math.random() < 0.5) this.pluck(chords[this.chordIdx][2] * 2);
}, this.mood === 'tense' ? 5200 : 8000);
}
private playChord(freqs: number[]) {
if (!this.ctx) return;
const c = this.ctx, t = c.currentTime;
// release old
for (const o of this.padOscs) { try { o.stop(t + 3); } catch { /* already stopped */ } }
this.padOscs = [];
const g = c.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.16, t + 2.4);
g.gain.linearRampToValueAtTime(0.0001, t + (this.mood === 'tense' ? 5.6 : 8.6));
g.connect(this.padGain);
for (const f of freqs) {
for (const det of [-2.5, 2.5]) {
const o = c.createOscillator();
o.type = this.mood === 'tense' ? 'sawtooth' : 'triangle';
o.frequency.value = f; o.detune.value = det;
o.connect(g); o.start(t);
this.padOscs.push(o);
}
}
}
private pluck(f: number) {
if (!this.ctx) return;
const c = this.ctx, t = c.currentTime;
const o = c.createOscillator(); o.type = 'sine'; o.frequency.value = f;
const g = c.createGain();
g.gain.setValueAtTime(0.06, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 2.2);
o.connect(g); g.connect(this.musBus);
o.start(t); o.stop(t + 2.3);
}
private scheduleThunder() {
if (this.thunderTimer) return;
this.thunderTimer = window.setInterval(() => {
if (!this.ctx || document.hidden) return;
if (Math.random() < 0.35) this.thunder();
}, 9000);
}
stopThunderLoop() {
if (this.thunderTimer) { clearInterval(this.thunderTimer); this.thunderTimer = 0; }
}
/* ------------ SFX ------------ */
sfx(name: 'click' | 'confirm' | 'deny' | 'loot' | 'shot' | 'hit' | 'death' | 'thunder' | 'day' | 'build') {
if (!this.ctx || this.muted) return;
const c = this.ctx, t = c.currentTime;
const tone = (f: number, dur: number, type: OscillatorType, vol: number, slide = 0) => {
const o = c.createOscillator(), g = c.createGain();
o.type = type; o.frequency.setValueAtTime(f, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, f + slide), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(this.sfxBus);
o.start(t); o.stop(t + dur + 0.05);
};
const burst = (dur: number, vol: number, hpFreq: number) => {
const src = c.createBufferSource(); src.buffer = this.noiseBuf;
src.playbackRate.value = 0.6 + Math.random() * 0.5;
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = hpFreq;
const g = c.createGain();
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(f); f.connect(g); g.connect(this.sfxBus);
src.start(t); src.stop(t + dur + 0.05);
};
switch (name) {
case 'click': tone(660, 0.07, 'square', 0.05); break;
case 'confirm': tone(520, 0.1, 'sine', 0.09); setTimeout(() => this.safeTone(780, 0.14, 'sine', 0.09), 70); break;
case 'deny': tone(140, 0.18, 'sawtooth', 0.07, -40); break;
case 'loot': [660, 880, 1320].forEach((f, i) => setTimeout(() => this.safeTone(f, 0.22, 'sine', 0.08), i * 80)); break;
case 'shot': burst(0.16, 0.5, 700); tone(90, 0.14, 'square', 0.12, -50); break;
case 'hit': burst(0.12, 0.35, 200); tone(70, 0.16, 'sine', 0.16, -25); break;
case 'death': [330, 262, 196, 131].forEach((f, i) => setTimeout(() => this.safeTone(f, 0.5, 'triangle', 0.1), i * 240)); break;
case 'thunder': burst(1.6, 0.5, 60); tone(46, 1.8, 'sine', 0.2, -14); break;
case 'day': tone(392, 0.16, 'sine', 0.07); setTimeout(() => this.safeTone(523, 0.3, 'sine', 0.07), 150); break;
case 'build': burst(0.1, 0.25, 400); setTimeout(() => this.safeTone(196, 0.2, 'square', 0.06), 90); break;
}
}
private safeTone(f: number, d: number, ty: OscillatorType, v: number) {
if (!this.ctx || this.muted) return;
const c = this.ctx, t = c.currentTime;
const o = c.createOscillator(), g = c.createGain();
o.type = ty; o.frequency.value = f;
g.gain.setValueAtTime(v, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + d);
o.connect(g); g.connect(this.sfxBus);
o.start(t); o.stop(t + d + 0.05);
}
thunder() { this.sfx('thunder'); }
}
export const audio = new GameAudio();
+83
View File
@@ -0,0 +1,83 @@
import type { BuildingDef } from '../engine/types';
export const BUILDINGS: BuildingDef[] = [
{
id: 'shelter', name: 'Shelter', icon: '🏚', maxLevel: 3,
levels: [
{ cost: { materials: 8 }, desc: 'Patched roofs, real beds. +group morale floor.' },
{ cost: { materials: 16, tools: 1 }, desc: 'Insulated walls. Cold hurts less; +1 action per day.' },
{ cost: { materials: 28, tools: 1, fuel: 4 }, desc: 'A real home. Strong morale bonus, storm protection.' },
],
effectText: 'Morale floor • cold & storm protection • +AP at level 2',
},
{
id: 'water_collector', name: 'Water Collector', icon: '💧', maxLevel: 3,
levels: [
{ cost: { materials: 6 } , desc: '+2 water/day (+6 in rain or storms).' },
{ cost: { materials: 12 }, desc: '+4 water/day (+10 in rain).' },
{ cost: { materials: 20 }, desc: '+7 water/day, enough to share with travelers.' },
],
effectText: 'Passive clean water every morning',
},
{
id: 'garden', name: 'Garden', icon: '🌱', maxLevel: 3,
levels: [
{ cost: { materials: 8, tools: 1 }, desc: '+1 food/day.' },
{ cost: { materials: 14, tools: 1 }, desc: '+3 food/day.' },
{ cost: { materials: 24, tools: 1 }, desc: '+5 food/day. Green in a gray world (+morale).' },
],
effectText: 'Grows food • Green Thumb survivors boost yield',
},
{
id: 'workshop', name: 'Workshop', icon: '🔧', maxLevel: 2,
levels: [
{ cost: { materials: 10, tools: 1 }, desc: 'Repair actions restore more integrity; craft ammo from scrap.' },
{ cost: { materials: 18, tools: 2 }, desc: '-15% build costs; can reload spent ammunition casings.' },
],
effectText: 'Repairs • crafting • build discount',
},
{
id: 'medical', name: 'Medical Station', icon: '⛑', maxLevel: 3,
levels: [
{ cost: { materials: 12, medicine: 3 }, desc: '+25% healing efficiency. Unlocks medical events.' },
{ cost: { materials: 18, medicine: 5 }, desc: '+50% healing. Automatic treatment of the worst injury each night.' },
{ cost: { materials: 26, medicine: 8 }, desc: 'A proper infirmary. Illness rarely fatal here.' },
],
effectText: 'Healing speed • night treatment • unlocks medical events',
},
{
id: 'storage', name: 'Storage', icon: '📦', maxLevel: 3,
levels: [
{ cost: { materials: 8 }, desc: '+40 food/water cap, +25 other resources.' },
{ cost: { materials: 14 }, desc: '+80/+50 caps.' },
{ cost: { materials: 22 }, desc: '+120/+75 caps. A stockpile worth defending.' },
],
effectText: 'Raises resource caps',
},
{
id: 'watchtower', name: 'Watchtower', icon: '🗼', maxLevel: 2,
levels: [
{ cost: { materials: 14 }, desc: 'Night raids much less likely to surprise you. +defense.' },
{ cost: { materials: 22, tools: 1 }, desc: 'Floodlight: ambush risk down everywhere, defense up.' },
],
effectText: 'Defense vs raids • early warning • less ambush',
},
{
id: 'generator', name: 'Generator', icon: '⚡', maxLevel: 2,
levels: [
{ cost: { materials: 16, fuel: 5 }, desc: 'Burns 1 fuel/day. Light at night (+morale). Enables the radio.' },
{ cost: { materials: 24, fuel: 6, tools: 1 }, desc: 'Also powers a heater (cold immunity) and workshop tools.' },
],
effectText: 'Light & heat • radio operations (endgame)',
},
];
export function buildingById(id: string): BuildingDef {
return BUILDINGS.find(b => b.id === id)!;
}
export function buildCost(id: string, targetLevel: number): Partial<Record<string, number>> {
const def = buildingById(id);
const raw = def.levels[targetLevel - 1]?.cost ?? {};
return { ...raw };
}
+40
View File
@@ -0,0 +1,40 @@
import type { EnemyDef } from '../engine/types';
/* Enemy archetypes. Strength scales with day via engine.enemyScale(). */
export const ENEMIES: Record<string, EnemyDef> = {
looter: {
kind: 'looter', name: 'Looters', strength: 26, perception: 30, speed: 40, greed: 6,
desc: 'Two hungry men with pipes and a shopping cart. They were people once, too.',
loot: [{ res: 'food', min: 1, max: 4, weight: 3 }, { res: 'materials', min: 1, max: 3, weight: 2 }],
},
dogpack: {
kind: 'dogpack', name: 'Feral Dogs', strength: 20, perception: 55, speed: 70, greed: 0,
desc: 'Ribs and teeth. The dust took their owners; the hunger kept them.',
loot: [],
},
militant: {
kind: 'militant', name: 'Armed Scavengers', strength: 42, perception: 45, speed: 45, greed: 12,
desc: 'Matching armbands, working rifles. Discipline is scarier than rage.',
loot: [{ res: 'ammo', min: 2, max: 6, weight: 3 }, { res: 'fuel', min: 1, max: 3, weight: 1 }, { res: 'medicine', min: 1, max: 2, weight: 1 }],
},
feral: {
kind: 'feral', name: 'The Dust-Sick', strength: 34, perception: 25, speed: 30, greed: 0,
desc: 'They breathed too much Grayfall. They are not dead. You wish they were.',
loot: [{ res: 'materials', min: 1, max: 2, weight: 1 }],
},
raiderband: {
kind: 'raiderband', name: 'Raider Band', strength: 55, perception: 40, speed: 50, greed: 18,
desc: 'They take supplies first. Then they take turns deciding who goes.',
loot: [{ res: 'food', min: 2, max: 6, weight: 2 }, { res: 'ammo', min: 2, max: 5, weight: 2 }, { res: 'fuel', min: 1, max: 4, weight: 1 }],
},
convoy_guards: {
kind: 'convoy_guards', name: 'Convoy Guards', strength: 60, perception: 60, speed: 40, greed: 8,
desc: 'Uniforms that still fit. They check your eyes for the sickness before anything else.',
loot: [{ res: 'ammo', min: 2, max: 4, weight: 1 }],
},
};
export function enemyById(kind: string): EnemyDef {
return ENEMIES[kind] ?? ENEMIES.looter;
}
+30
View File
@@ -0,0 +1,30 @@
import type { EventDef, GameState, Rng } from '../../engine/types';
import { PEOPLE_EVENTS } from './people';
import { WORLD_EVENTS } from './world';
import { STORY_EVENTS } from './story';
export const ALL_EVENTS: EventDef[] = [...PEOPLE_EVENTS, ...WORLD_EVENTS, ...STORY_EVENTS];
const byId = new Map<string, EventDef>(ALL_EVENTS.map(e => [e.id, e]));
export function getEvent(id: string): EventDef | undefined {
return byId.get(id);
}
/** Roll a random weighted event valid for this state. */
export function rollEvent(s: GameState, rng: Rng): EventDef | null {
const pool = ALL_EVENTS.filter(e => eligible(s, e));
if (!pool.length) return null;
const ev = rng.weighted(pool, e => e.weight);
return ev && ev.weight > 0 ? ev : null;
}
export function eligible(s: GameState, e: EventDef): boolean {
if (e.weight <= 0) return false;
if (e.minDay !== undefined && s.day < e.minDay) return false;
if (e.maxDay !== undefined && s.day > e.maxDay) return false;
if (e.once && s.seenEvents[e.id]) return false;
if (!e.once && (s.seenEvents[e.id] ?? 0) > s.day - 6) return false; // cooldown for repeatables
if (e.cond && !e.cond(s)) return false;
return true;
}
+622
View File
@@ -0,0 +1,622 @@
import type { EventDef, Survivor } from '../../engine/types';
import { hasTrait, skill } from '../survivors';
const A = (s: Survivor) => s.alive && !s.gone;
export const PEOPLE_EVENTS: EventDef[] = [
/* ------------------------------------------------ THE STRANGER */
{
id: 'stranger_wounded', title: 'The Stranger', tag: 'stranger', weight: 14, once: true,
music: 'tense',
cast: () => ({}),
text: (s, cast) => {
const who = cast.extras[0] ?? 'A man';
return `${who} limps into the edge of camp, one hand pressed to his side. Blood seeps between his fingers — old, but not old enough.\n\n"You look like you could use another pair of hands," he says. "And I look like I need a place to fall down."`;
},
choices: [
{
label: 'Help him', tip: 'Costs 2 medicine. He may join you.',
req: { res: { medicine: 2 } },
run: (ctx) => {
ctx.lose('medicine', 2);
const name = ctx.rng.pick(['Dario', 'Kofi', 'Teo', 'Wren', 'Farid', 'Ada']);
ctx.say(`You clean and stitch the wound while ${name} watches you with the stillness of someone used to being hurt alone.`);
ctx.flag('spared_stranger', 1);
ctx.flag('karma', 2);
ctx.queueEvent('stranger_returns', ctx.rng.i(4, 8), { name });
if (ctx.rng.chance(0.5)) {
const joined = ctx.joinGroup({ name });
if (joined) ctx.say(`${name} stays. He sleeps with his boots on for a week.`);
} else {
ctx.say('He rests one night, then slips away before dawn — leaving a hand-drawn map of the district on a crate.');
ctx.discoverRandom();
}
},
},
{
label: 'Question him', tip: 'Needs Charisma 5+ to read him truly.',
req: { skill: { skill: 'charisma', min: 5 } },
run: (ctx) => {
const name = ctx.rng.pick(['Dario', 'Kofi', 'Teo', 'Wren']);
if (ctx.rng.chance(0.6)) {
ctx.say(`His story holds. Deserted convoy, brother lost on the coastal road, three weeks alone. ${name} is telling the truth.`);
ctx.flag('spared_stranger', 1);
ctx.flag('karma', 1);
ctx.queueEvent('stranger_returns', ctx.rng.i(4, 8), { name });
ctx.say('You give him a day\'s rations and directions around your camp. He nods like a man memorizing a kindness.');
} else {
ctx.say(`His story changes twice in ten minutes. You send him off before he learns where your supplies sleep. He watches the camp a long time from the tree line.`);
ctx.flag('stranger_angry', 1);
ctx.queueEvent('stranger_revenge', ctx.rng.i(5, 9), { name });
}
},
},
{
label: 'Turn him away',
run: (ctx) => {
ctx.say('He doesn\'t argue. That is somehow worse. He limps back into the gray and you think about him for days.');
ctx.morale('all', -4);
ctx.flag('karma', -1);
if (ctx.rng.chance(0.35)) ctx.queueEvent('stranger_revenge', ctx.rng.i(6, 12), { name: 'The stranger' });
},
},
],
},
{
id: 'stranger_returns', title: 'A Debt Repaid', tag: 'stranger', weight: 0,
text: (_s, cast) => `${cast.extras[0] ?? 'The stranger'} comes back at dusk — walking straighter, carrying a canvas bag.\n\n"I said I don\'t forget. This is me not forgetting."`,
choices: [
{
label: 'Welcome him in',
run: (ctx) => {
const joined = ctx.joinGroup({ name: ctx.cast.extras[0] });
if (joined) {
ctx.say(`${joined.name} joins the camp for good. In the bag: tinned food and a working flare gun.`);
ctx.gain('food', ctx.rng.i(3, 6));
ctx.flag('karma', 1);
}
},
},
{
label: 'Take the gift, not the risk',
run: (ctx) => {
ctx.gain('food', ctx.rng.i(2, 4));
ctx.gain('ammo', ctx.rng.i(1, 3));
ctx.say('He leaves the bag and goes. Some people only fit in a story, not a camp.');
},
},
],
},
{
id: 'stranger_revenge', title: 'He Came Back', tag: 'stranger', weight: 0, music: 'tense',
text: (_s, cast) => `In the gray hour before dawn, ${cast.extras[0] ?? 'the man you turned away'} returns — with friends, and without the limp.\n\nThey cut through the fence line toward the storage.`,
choices: [
{
label: 'Fight them off', tip: 'Tactical encounter.',
run: (ctx) => {
const party = ctx.s.survivors.filter(A).slice(0, 2);
ctx.encounter({
enemyName: 'The Stranger\'s Crew', enemyDesc: 'Desperate men with knives and a grudge.',
strength: 30, perception: 30, speed: 40, greed: 0, loot: [],
party: party.map(p => p.id),
intro: 'They come in fast and quiet.', context: 'camp',
});
ctx.flag('karma', -0);
},
},
{
label: 'Scare them off with fire', tip: 'Costs 1 fuel.',
req: { res: { fuel: 1 } },
run: (ctx) => {
ctx.lose('fuel', 1);
ctx.say('A burning barrel rolled into their path breaks their nerve. They scatter — but they\'ll remember the camp that burned them.');
ctx.flag('karma', -1);
},
},
{
label: 'Give up supplies to make them leave', tip: 'Lose food and materials.',
run: (ctx) => {
ctx.lose('food', Math.min(4, ctx.s.res.food));
ctx.lose('materials', Math.min(3, ctx.s.res.materials));
ctx.say('They take what you offer and vanish into the fog. Nobody says anything for a long time. Nobody has to.');
ctx.morale('all', -6);
},
},
],
},
/* ------------------------------------------------ HUNGRY FAMILY */
{
id: 'hungry_family', title: 'The Family at the Fence', tag: 'stranger', weight: 12,
text: () => `A woman and two children stand at the fence line. The children have the gray-lipped look of people who haven\'t eaten in days.\n\n"We\'re not asking you to take us in," the woman says. "Just whatever you can spare."`,
choices: [
{
label: 'Share food', tip: 'Costs 3 food.',
req: { res: { food: 3 } },
run: (ctx) => {
ctx.lose('food', 3);
ctx.flag('karma', 2);
ctx.flag('fed_family', 1);
ctx.say('The children eat like it\'s a language they\'d forgotten. The woman memorizes your faces — the good way.');
ctx.morale('all', 5);
if (ctx.rng.chance(0.5)) ctx.queueEvent('family_gift', ctx.rng.i(6, 14));
},
},
{
label: 'Trade for information', tip: 'Costs 2 food, learn a location.',
req: { res: { food: 2 } },
run: (ctx) => {
ctx.lose('food', 2);
ctx.flag('karma', 1);
const loc = ctx.discoverRandom();
ctx.say(loc ? `In exchange, the woman marks a place on your map: ${loc.name}. "People stopped coming back from there. Make of that what you want."` : 'They tell you about the road east. You already knew.');
},
},
{
label: 'Turn them away',
run: (ctx) => {
ctx.say('The woman nods like she expected it. The smallest child waves goodbye. Nobody waves back.');
ctx.morale('all', -5);
ctx.flag('karma', -2);
},
},
],
},
{
id: 'family_gift', title: 'A Package at the Fence', tag: 'stranger', weight: 0,
text: () => `Tied to the gate is a package with a note in careful handwriting:\n\n"You fed my kids. I won\'t forget. Don\'t go near the river after dark — that\'s all I\'ll say."`,
choices: [
{
label: 'Take it',
run: (ctx) => {
const r = ctx.rng.i(2, 5);
ctx.gain('medicine', ctx.rng.i(1, 2));
ctx.gain('food', r);
ctx.say('Inside: medicine, canned food, and a child\'s drawing of your camp with a sun over it.');
ctx.morale('all', 4);
},
},
],
},
/* ------------------------------------------------ TRADER CARAVAN */
{
id: 'trader_caravan', title: 'The Caravan', tag: 'trade', weight: 10, minDay: 4,
text: () => `Three trucks crawl past camp, flying a yellow trade-flag. A broker with a ledger hops down and spreads his hands.\n\n"Fair rates. No guns pointed, no knives out. The old way."`,
choices: [
{
label: 'Trade medicine for food', tip: '-2 medicine, +7 food',
req: { res: { medicine: 2 } },
run: (ctx) => { ctx.lose('medicine', 2); ctx.gain('food', 7); ctx.say('The broker counts twice, pays fair, and doesn\'t ask where the medicine came from.'); ctx.flag('karma', 1); },
},
{
label: 'Buy a weapon', tip: '-8 materials, gain a machete',
req: { res: { materials: 8 }, noFlag: 'got_machete' },
run: (ctx) => {
ctx.lose('materials', 8); ctx.flag('got_machete', 1);
const taker = ctx.rng.pick(ctx.s.survivors.filter(A));
if (taker && !taker.weapon) { taker.weapon = 'machete'; ctx.say(`${taker.name} takes the machete and tests the weight. "Balanced," ${taker.name} admits.`); ctx.memory(taker, 'Traded for a machete at the caravan.'); }
else ctx.say('You buy the machete and lock it in the armory crate.');
ctx.gain('tools', 1);
},
},
{
label: 'Buy fuel', tip: '-5 food, +6 fuel',
req: { res: { food: 5 } },
run: (ctx) => { ctx.lose('food', 5); ctx.gain('fuel', 6); ctx.say('Fuel is currency now. The broker treats gasoline like gold bars.'); ctx.flag('karma', 0); },
},
{
label: 'Rob the caravan', tip: 'Take everything — if you win. They will remember.',
run: (ctx) => {
ctx.flag('robbed_caravan', 1);
ctx.flag('karma', -3);
const party = ctx.s.survivors.filter(A).slice(0, 2);
ctx.encounter({
enemyName: 'Caravan Guards', enemyDesc: 'Trade-flag or not, they are armed and ready.',
strength: 38, perception: 45, speed: 45, greed: 0,
loot: [{ res: 'food', min: 3, max: 7, weight: 3 }, { res: 'fuel', min: 2, max: 5, weight: 2 }, { res: 'materials', min: 2, max: 5, weight: 2 }],
party: party.map(p => p.id), intro: 'The broker\'s smile is the first thing to go.', context: 'event',
});
ctx.queueEvent('caravan_revenge', ctx.rng.i(10, 22));
},
},
{ label: 'Wave them past', run: (ctx) => { ctx.say('The trucks grind east. The broker tips an invisible hat.'); } },
],
},
{
id: 'caravan_revenge', title: 'The Yellow Flag Remembers', tag: 'trade', weight: 0, music: 'tense',
text: () => `They come at night with headlights off — the caravan people, or what's left of their crew. Word travels. Word about you.\n\n"You know why we\'re here," a voice calls from the dark.`,
choices: [
{
label: 'Defend the camp', run: (ctx) => {
const party = ctx.s.survivors.filter(A).slice(0, 3);
ctx.encounter({
enemyName: 'Caravan Enforcers', enemyDesc: 'They fight like professionals with a grudge.',
strength: 46, perception: 50, speed: 40, greed: 0,
loot: [{ res: 'ammo', min: 2, max: 5, weight: 2 }, { res: 'fuel', min: 1, max: 3, weight: 1 }],
party: party.map(p => p.id), intro: 'Muzzle flashes stitch the fence line.', context: 'camp',
});
},
},
{
label: 'Return what you took and beg peace', tip: 'Lose food and fuel.',
req: { res: { food: 4 } },
run: (ctx) => {
ctx.lose('food', 4); ctx.lose('fuel', Math.min(3, ctx.s.res.fuel));
ctx.say('You set the goods on the ground and step back. A long silence. Then: "Smart." The headlights swing away. Shame keeps everyone alive tonight.');
ctx.morale('all', -4);
},
},
],
},
/* ------------------------------------------------ INTERNAL: argument */
{
id: 'camp_argument', title: 'Voices Rising', tag: 'internal', weight: 12, minDay: 3,
cast: (s) => {
const alive = s.survivors.filter(A);
let worst: [Survivor, Survivor] | null = null;
let min = 999;
for (let i = 0; i < alive.length; i++) for (let j = i + 1; j < alive.length; j++) {
const v = s.rel[relKey(alive[i].id, alive[j].id)] ?? 0;
if (v < min) { min = v; worst = [alive[i], alive[j]]; }
}
return worst ? { extras: [worst[0].id, worst[1].id] } : {};
},
text: (s, cast) => {
const [a, b] = cast.extras.map(id => s.survivors.find(x => x.id === id)!);
return `It starts over dishes and ends up everywhere at once. ${a.name} and ${b.name} are shouting in the common room, and the whole camp has stopped pretending not to listen.`;
},
choices: [
{
label: 'Mediate calmly', tip: 'Charisma check.',
run: (ctx) => {
const [a, b] = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!);
const leader = bestAt(ctx.s, 'charisma');
if (skill(leader, 'charisma') >= 5 || ctx.rng.chance(0.5)) {
ctx.say(`${leader.name} gets them talking instead of shouting. By the end there\'s a plan for the dishes and an uneasy peace for everything else.`);
ctx.rel(a.id, b.id, 8); ctx.morale(a, 4); ctx.morale(b, 4);
} else {
ctx.say('Somehow you make it worse. Now all three of you are angry.');
ctx.rel(a.id, b.id, -4); ctx.morale(a, -3); ctx.morale(b, -3);
}
},
},
{
label: 'Side with the first', run: (ctx) => {
const [a, b] = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!);
ctx.rel(a.id, b.id, -10); ctx.morale(a, 5); ctx.morale(b, -8);
ctx.say(`You rule for ${a.name}. ${b.name} goes quiet in a way that lasts for days.`);
if (hasTrait(b, 'hotheaded')) { ctx.morale(b, -4); ctx.memory(b, `The leader took ${a.name}'s side against me.`, 'bad'); }
},
},
{
label: 'Let them fight it out', run: (ctx) => {
const [a, b] = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!);
if (ctx.rng.chance(0.5)) {
ctx.say('It ends in a shoving match, then — strangely — laughter. Something has burned out of the air.');
ctx.rel(a.id, b.id, 5);
} else {
const loser = ctx.rng.pick([a, b]);
ctx.hurt(loser, 'cut', 'Busted lip', 1, 3);
ctx.rel(a.id, b.id, -12);
ctx.say(`It gets physical before anyone can move. ${loser.name} splits a lip; the camp splits quietly into sides.`);
}
},
},
],
},
/* ------------------------------------------------ INTERNAL: romance */
{
id: 'romance_blooms', title: 'Something Soft', tag: 'internal', weight: 8, minDay: 8,
cond: (s) => coupleCandidates(s).length > 0,
cast: (s) => {
const c = coupleCandidates(s)[0];
return c ? { extras: [c[0].id, c[1].id] } : {};
},
text: (s, cast) => {
const [a, b] = cast.extras.map(id => s.survivors.find(x => x.id === id)!);
return `You find ${a.name} and ${b.name} on watch together, sitting closer than the cold requires. They don\'t notice you. The world ends and people still find each other — maybe because it ended.`;
},
choices: [
{
label: 'Leave them to it',
run: (ctx) => {
const [a, b] = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!);
a.coupleWith = b.id; b.coupleWith = a.id;
ctx.rel(a.id, b.id, 10);
ctx.memory(a, `Watched the stars with ${b.name}. Felt human.`, 'good');
ctx.memory(b, `${a.name} and I stopped pretending.`, 'good');
ctx.morale(a, 8); ctx.morale(b, 8); ctx.morale('all', 2);
ctx.say('You back away without a sound. Some things the camp doesn\'t need to vote on.');
},
},
{
label: 'Encourage them openly', tip: 'Group morale up, but camp gossip.',
run: (ctx) => {
const [a, b] = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!);
a.coupleWith = b.id; b.coupleWith = a.id;
ctx.rel(a.id, b.id, 6); ctx.morale('all', 5);
ctx.say('You announce it at dinner like good news, because it is. Someone produces a dusty bottle. For one night the camp feels like the before-times.');
ctx.memory(a, `The whole camp cheered for us. Embarrassing. Wonderful.`, 'good');
},
},
],
},
/* ------------------------------------------------ INTERNAL: theft */
{
id: 'theft_caught', title: 'Missing Rations', tag: 'internal', weight: 10, minDay: 5,
cond: (s) => s.survivors.some(x => A(x) && (x.morale < 45 || hasTrait(x, 'greedy'))),
cast: (s) => {
const pool = s.survivors.filter(x => A(x) && (x.morale < 45 || hasTrait(x, 'greedy')));
return pool.length ? { speaker: pool[0] } : {};
},
text: (s, cast) => `${cast.speaker?.name ?? 'Someone'} has been taking extra rations at night. You find the wrappers under their bunk. Everyone else has already done the same math.`,
choices: [
{
label: 'Confront publicly', run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.morale(t, -12); ctx.morale('all', -2);
const bf = bestFriendOf(ctx.s, t.id);
if (bf) ctx.rel(t.id, bf.id, -5);
ctx.say(`${t.name} stands red-faced through the whole camp meeting. The rations go back. Something else goes out of ${t.name}'s eyes.`);
ctx.memory(t, 'Caught stealing. Everyone saw.', 'bad');
},
},
{
label: 'Speak to them privately', run: (ctx) => {
const t = ctx.cast.speaker!;
if (ctx.rng.chance(0.65)) {
ctx.morale(t, 10);
ctx.say(`You talk to ${t.name} where no one can see. They cry, briefly, and give the food back. "My brother starved while I slept," ${t.name} says. "I can\'t close my eyes now."`);
ctx.memory(t, 'The leader understood about the rations.', 'good');
} else {
ctx.morale(t, -4);
ctx.say(`${t.name} promises it won\'t happen again. The wrappers under the bunk say otherwise next week.`);
}
},
},
{
label: 'Let it slide', run: (ctx) => {
ctx.morale('all', -4);
ctx.say('You say nothing. The camp notices you saying nothing. Fairness starts to feel like a rumor.');
ctx.flag('karma', -1);
},
},
],
},
/* ------------------------------------------------ SICKNESS */
{
id: 'fever_night', title: 'Burning Up', tag: 'medical', weight: 11, minDay: 4,
cond: (s) => s.survivors.some(A),
cast: (s) => ({ speaker: weakest(s) }),
text: (s, cast) => `${cast.speaker?.name ?? 'One of the group'} is shivering under three blankets, skin hot as a stove-top. The cough has a wet, wrong sound to it.`,
choices: [
{
label: 'Use medicine', tip: '-2 medicine, sure cure.',
req: { res: { medicine: 2 } },
run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.lose('medicine', 2);
t.sick = false; ctx.morale(t, 6);
ctx.say('The fever breaks near dawn. ' + t.name + ' keeps the empty pill bottle as a souvenir of the worst night of the month.');
ctx.memory(t, 'The camp spent its medicine on me. I owe them.', 'good');
},
},
{
label: 'Rest and hope', run: (ctx) => {
const t = ctx.cast.speaker!;
if (ctx.rng.chance(0.55)) {
ctx.say('Sweat, water, and time. The fever breaks on the second day on its own.');
t.sick = false;
} else {
ctx.hp(t, -18); t.sick = true;
ctx.say('The fever digs in. ' + t.name + ' goes gray and quiet. This is how it starts, the old-timers say. This is exactly how it starts.');
}
},
},
],
},
/* ------------------------------------------------ CHILD ALONE */
{
id: 'teen_alone', title: 'The Kid on the Road', tag: 'stranger', weight: 9, once: true,
text: () => `A teenager stands in the middle of the road like a fence post — dusty backpack, dead phone, eyes that stopped crying a while ago.\n\n"My dad said wait here. That was nine days ago."`,
choices: [
{
label: 'Take them in', tip: 'Another mouth. Another pair of hands.',
run: (ctx) => {
const j = ctx.joinGroup({ name: ctx.rng.pick(['Quinn', 'Esme', 'Luca', 'Xiu']) });
if (j) {
j.age = ctx.rng.i(15, 18); j.occ = 'student'; j.occLabel = 'Student';
ctx.say(`${j.name} doesn\'t talk for two days. On the third day they fix the camp stove without being asked. Kids bend; they don\'t always break.`);
ctx.flag('karma', 2); ctx.morale('all', 3);
}
},
},
{
label: 'Give food and directions', tip: '-2 food.',
req: { res: { food: 2 } },
run: (ctx) => {
ctx.lose('food', 2); ctx.flag('karma', 1);
ctx.say('You point them toward the settlement rumors to the south. They walk until the road bends. You watch until you can\'t.');
ctx.morale('all', -2);
},
},
{ label: 'Drive them off', run: (ctx) => { ctx.say('They run. You go back to work. The work doesn\'t help.'); ctx.morale('all', -6); ctx.flag('karma', -3); } },
],
},
/* ------------------------------------------------ BIRTHDAY */
{
id: 'birthday', title: 'A Date That Matters', tag: 'internal', weight: 7, minDay: 6,
cast: (s) => ({ speaker: s.survivors.filter(A)[0] ?? null }),
text: (s, cast) => `${cast.speaker?.name ?? 'Someone'} mentions, almost apologetically, that today is their birthday. The camp goes quiet in a specific way. People are counting what a celebration would cost.`,
choices: [
{
label: 'Throw a party', tip: '-2 food, +big morale.',
req: { res: { food: 2 } },
run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.lose('food', 2); ctx.morale('all', 10); ctx.morale(t, 12);
ctx.say('There\'s no cake. There are two candles in a tin of pears, and a song nobody finishes, and for an hour the camp glows warmer than the generator ever could.');
ctx.memory(t, 'They threw me a birthday party at the end of the world.', 'good');
},
},
{
label: 'Mark it quietly', run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.morale(t, 4);
ctx.say('An extra handful of water in the canteen. A handshake held one second too long. It\'s enough. It has to be enough.');
ctx.memory(t, 'Quiet birthday. They remembered.', 'good');
},
},
{ label: 'There\'s no room for birthdays', run: (ctx) => { const t = ctx.cast.speaker!; ctx.morale(t, -8); ctx.say('You see the moment land on ' + t.name + ' like weather. The day continues.'); ctx.memory(t, 'Nobody even said happy birthday.', 'bad'); } },
],
},
/* ------------------------------------------------ OLD FRIEND / DEBT */
{
id: 'old_debt', title: 'Someone From Before', tag: 'stranger', weight: 8, minDay: 7,
cast: (s) => ({ speaker: s.survivors.filter(A)[0] ?? null }),
text: (s, cast) => {
const t = cast.speaker;
return `A scavenger at the fence recognizes ${t?.name ?? 'one of yours'} from the before-times. "You owe me from the office days," the scavenger laughs. "I\'m calling it in. Things are bad out here."`;
},
choices: [
{
label: 'Pay the debt', tip: '-3 food or materials.',
req: { res: { food: 3 } },
run: (ctx) => {
ctx.lose('food', 3); ctx.flag('karma', 1);
const t = ctx.cast.speaker!;
ctx.say('You pay. The scavenger salutes with two fingers and, on the way out, mentions which houses on the ridge still have cellars full of preserves.');
ctx.memory(t, 'An old debt got paid on my behalf.', 'neutral');
ctx.discoverRandom();
},
},
{
label: 'Refuse — the old world is gone', run: (ctx) => {
ctx.say('"Gone," the scavenger repeats, and spits, and leaves. The phrase follows the camp around for a week.');
const t = ctx.cast.speaker!; ctx.morale(t, -5);
ctx.memory(t, 'Someone from before came asking. We sent them away empty.', 'bad');
},
},
{
label: 'Offer them a place in the camp', run: (ctx) => {
const j = ctx.joinGroup();
if (j) ctx.say(`${j.name} stays. The past keeps arriving at the fence line; some of it you let in.`);
else ctx.say('There\'s no room. The scavenger understands. Understanding doesn\'t help.');
},
},
],
},
/* ------------------------------------------------ DESERTION WATCH */
{
id: 'restlessness', title: 'Restless', tag: 'internal', weight: 9, minDay: 10,
cond: (s) => s.survivors.some(x => A(x) && x.morale < 35),
cast: (s) => ({ speaker: s.survivors.filter(x => A(x) && x.morale < 35)[0] ?? null }),
text: (s, cast) => `${cast.speaker?.name ?? 'Someone'} has started standing at the fence in the evenings, looking at the road south. Not hiding it, either. That\'s the part that worries people.`,
choices: [
{
label: 'Talk them down', tip: 'Charisma check.',
run: (ctx) => {
const t = ctx.cast.speaker!;
if (skill(bestAt(ctx.s, 'charisma'), 'charisma') >= 6) {
ctx.morale(t, 14);
ctx.say('You sit with them until the light dies. They talk about a sister in the southern settlements. Then they talk about tomorrow\'s water run. That\'s how you know they\'re staying.');
ctx.memory(t, 'Almost walked. The leader talked me back.', 'good');
} else {
ctx.morale(t, -2);
ctx.say('You say the right words in the wrong order. ' + t.name + ' nods and keeps looking south.');
}
},
},
{
label: 'Promise them something real', tip: '-2 food now, strong effect.',
req: { res: { food: 2 } },
run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.lose('food', 2); ctx.morale(t, 10);
ctx.say('You hand over the best blanket and the last chocolate and say: "Day 100. We all walk out together." ' + t.name + ' holds the chocolate like a promise with a wrapper.');
ctx.memory(t, 'Day 100. We walk out together.', 'good');
ctx.flag('hope', 1);
},
},
{
label: 'Let them go', run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.leaveGroup(t, 'Walked away from the camp');
ctx.say(`${t.name} takes a canteen and leaves at first light. The fence looks bigger without them.`);
ctx.morale('all', -6);
},
},
],
},
/* ------------------------------------------------ GRAVE WATCH */
{
id: 'night_watch_story', title: 'The Long Watch', tag: 'internal', weight: 8,
cast: (s) => ({ speaker: s.survivors.filter(A)[0] ?? null }),
text: (s, cast) => `You find ${cast.speaker?.name ?? 'one of the group'} on the night shift, and instead of relief they ask you to stay a while. The dark out there has been making noises at them.`,
choices: [
{
label: 'Stay and talk', run: (ctx) => {
const t = ctx.cast.speaker!;
ctx.morale(t, 7);
ctx.rel(t.id, ctx.s.survivors.find(x => A(x) && x.id !== t.id)?.id ?? '', 3);
ctx.say('Two hours pass like ten minutes. They tell you about a lake from before, and you almost smell the water. The noises in the dark stay outside the story, where they belong.');
ctx.memory(t, 'The long watch, shared.', 'good');
},
},
{ label: 'You have work to do', run: (ctx) => { ctx.morale(ctx.cast.speaker!, -2); ctx.say('You go. The watch goes back to being a person alone with the dark.'); } },
],
},
];
/* ---------------- helpers shared by event defs ---------------- */
export function relKey(a: string, b: string): string {
return [a, b].sort().join('|');
}
export function coupleCandidates(s: import('../../engine/types').GameState): [Survivor, Survivor][] {
const alive = s.survivors.filter(x => A(x) && !x.coupleWith);
const out: [Survivor, Survivor][] = [];
for (let i = 0; i < alive.length; i++) {
for (let j = i + 1; j < alive.length; j++) {
const v = s.rel[relKey(alive[i].id, alive[j].id)] ?? 0;
if (v >= 55) out.push([alive[i], alive[j]]);
}
}
return out;
}
export function bestAt(s: import('../../engine/types').GameState, k: import('../../engine/types').SkillId): Survivor {
const alive = s.survivors.filter(A);
if (!alive.length) throw new Error('no survivors');
return alive.reduce((best, x) => (skill(x, k) > skill(best, k) ? x : best), alive[0]);
}
export function weakest(s: import('../../engine/types').GameState): Survivor {
const alive = s.survivors.filter(A);
return alive.reduce((w, x) => (x.hp < w.hp ? x : w), alive[0]);
}
export function bestFriendOf(s: import('../../engine/types').GameState, ofId: string): Survivor | null {
let best: Survivor | null = null; let bv = 0;
for (const x of s.survivors) {
if (x.id === ofId || !A(x)) continue;
const v = s.rel[relKey(ofId, x.id)] ?? 0;
if (v > bv) { bv = v; best = x; }
}
return best;
}
+609
View File
@@ -0,0 +1,609 @@
import type { EventDef, GameState } from '../../engine/types';
import { bestAt } from './people';
const A = (s: import('../../engine/types').Survivor) => s.alive && !s.gone;
const alive = (s: GameState) => s.survivors.filter(A);
/* ============================================================
SCRIPTED CAMPAIGN BEATS
============================================================ */
export interface StoryBeat {
day: number;
eventId: string;
/** If cond fails, fire altEventId instead. */
cond?: (s: GameState) => boolean;
altEventId?: string;
}
export const STORY_BEATS: StoryBeat[] = [
{ day: 2, eventId: 'beat_first_light' },
{ day: 6, eventId: 'beat_radio_crackle' },
{ day: 10, eventId: 'beat_the_question' },
{ day: 16, eventId: 'beat_the_map' },
{ day: 21, eventId: 'beat_new_face' },
{ day: 28, eventId: 'beat_convoy_sighting' },
{ day: 35, eventId: 'beat_gang_ultimatum', cond: (s) => !s.flags.raider_paid, altEventId: 'beat_quiet_day' },
{ day: 42, eventId: 'beat_sickness_wave' },
{ day: 50, eventId: 'beat_halfway' },
{ day: 57, eventId: 'beat_prisoners', cond: (s) => !!s.flags.won_camp_defense, altEventId: 'beat_quiet_day' },
{ day: 63, eventId: 'beat_herd_year' },
{ day: 70, eventId: 'beat_the_truth', cond: (s) => (s.camp.buildings.generator ?? 0) >= 1, altEventId: 'beat_truth_static' },
{ day: 77, eventId: 'beat_convoy_offer', cond: (s) => (s.flags.militaryRep ?? 0) >= 2, altEventId: 'beat_no_contact' },
{ day: 84, eventId: 'beat_exodus_rumor' },
{ day: 90, eventId: 'beat_last_warning' },
{ day: 95, eventId: 'beat_last_prep' },
{ day: 99, eventId: 'beat_last_supper' },
];
/* ============================================================
BEAT EVENT DEFINITIONS
============================================================ */
export const STORY_EVENTS: EventDef[] = [
{
id: 'beat_first_light', title: 'First Light', tag: 'story', weight: 0, music: 'hopeful',
text: () => `Nobody sleeps well the second night. Every noise is a person; every silence is worse.\n\nBut morning comes anyway. The sun rises gray through the ash-haze, and the living get up, and count themselves, and begin.\n\nNinety-eight days remain.`,
choices: [{ label: 'Begin', run: (ctx) => { ctx.morale('all', 3); ctx.flag('hope', 1); } }],
},
{
id: 'beat_radio_crackle', title: 'Static With Teeth', tag: 'story', weight: 0,
text: () => `The camp's dead hand-crank radio coughs back to life for eleven seconds at midnight.\n\n"…repeat, this is UNCOR Southern Command… ashfall is NOT volcanic… do not eat what the dust touches…" Then static, like a door closing.\n\nSomeone was alive out there. Someone with information.`,
choices: [{
label: 'Log the frequency',
run: (ctx) => { ctx.flag('heardRadio', 1); ctx.flag('hope', 1); ctx.say('You tape the frequency to the wall. If anyone broadcasts again, you\'ll be listening.'); ctx.morale('all', 4); },
}],
},
{
id: 'beat_the_question', title: 'Why Are We Still Here?', tag: 'story', weight: 0,
text: (_s, cast) => `${cast.extras[0]} asks it over the fire, quietly, the way you ask something you've been carrying for weeks:\n\n"The world's gone. Everyone we knew is gone. What exactly are we surviving FOR?"\n\nNobody laughs. Everybody waits.`,
choices: [
{
label: '"For each other."', run: (ctx) => {
ctx.morale('all', 8); ctx.flag('hope', 2); ctx.flag('purpose_community', 1);
const t = ctx.cast.extras[0] ? ctx.s.survivors.find(x => x.id === ctx.cast.extras[0]) : null;
if (t) ctx.memory(t, 'Asked the hard question by the fire. Got an answer worth keeping.', 'good');
ctx.say('It lands right. Shoulders unknot. Someone feeds the fire higher. That night, for the first time, the camp sounds like people instead of survivors.');
},
},
{
label: '"To see Day 100 and know why."', run: (ctx) => {
ctx.morale('all', 4); ctx.flag('purpose_mystery', 1);
ctx.say('A harder answer for a harder mood. It doesn\'t comfort anyone, but it aims them. Some people can march a long way on a question.');
},
},
{
label: '"No reason. We just don\'t quit."', run: (ctx) => {
ctx.morale('all', 2);
ctx.say('Stubbornness as philosophy. A few grim smiles. It will hold — until the night it doesn\'t.');
},
},
],
},
{
id: 'beat_the_map', title: 'The Wider World', tag: 'story', weight: 0,
text: () => `In a flooded real estate office, someone finds a framed district survey map. Better than that: hand-drawn over it, in three different inks, are marks — stores, clinics, a checkpoint. Other survivors annotated their world before it ate them.\n\nThe map shows how close you've been sitting to everything.`,
choices: [{
label: 'Copy the marks',
run: (ctx) => {
const a = ctx.discoverRandom(); const b = ctx.discoverRandom();
ctx.say(`Two new places go onto your working map${a ? ': ' + a.name : ''}${a && b ? ' and ' + b.name : '.'}`);
ctx.morale('all', 3);
},
}],
},
{
id: 'beat_new_face', title: 'Knocking', tag: 'story', weight: 0,
text: () => `Three sharp knocks on the fence post at dusk — the old survivalist code, half-forgotten, still correct.\n\nA woman stands outside with her hands visible and a toolbox hanging from one shoulder. "Heard your generator," she says. "That means somebody's planning a future. I'd like in on that."`,
choices: [
{
label: 'Let her in',
run: (ctx) => {
const j = ctx.joinGroup({ name: ctx.rng.pick(['Sarah', 'Petra', 'Keiko', 'Noor']) });
if (j) {
j.occ = 'engineer'; j.occLabel = 'Engineer'; j.skills.engineering = Math.max(j.skills.engineering, 6);
ctx.say(`${j.name} earns her bunk before bedtime — fixes the collector pump that's been rattling for a week, then asks what needs building next.`);
ctx.gain('tools', 1);
ctx.morale('all', 4);
}
},
},
{
label: 'Trade, but stay closed',
run: (ctx) => {
ctx.gain('tools', 2);
ctx.say('She trades you tools for food and takes the rejection gracefully. "Gate\'s open if it ever isn\'t," she says, and walks into the dark like she owns it.');
},
},
],
},
{
id: 'beat_convoy_sighting', title: 'Armor on the Horizon', tag: 'story', weight: 0,
text: () => `At dawn, a military convoy grinds along the far highway — six vehicles, dust-plated, unhurried. They don't stop. Through binoculars you can see stencil markings: UNCOR SOUTHERN COMMAND.\n\nThey're heading somewhere with purpose. It's the first organized thing you've seen since the Collapse.`,
choices: [
{
label: 'Try to flag them down', tip: 'They may stop… or not.',
run: (ctx) => {
if ((ctx.s.flags.militaryRep ?? 0) >= 2 || ctx.rng.chance(0.3)) {
ctx.flag('militaryRep', 3); ctx.flag('metConvoy', 1);
ctx.say('A gun-truck peels off and stops at parade distance. Faces behind visors. A lieutenant takes your report on paper, actually listens, then hands down a card: a frequency. "Keep a light burning. When the window opens, we\'ll call."');
ctx.morale('all', 8); ctx.flag('hope', 2);
} else {
ctx.say('You wave a blanket on a pole. The convoy slows almost imperceptibly — then continues. Rules about stopping, probably. You memorize their direction anyway.');
ctx.flag('militarySeen', 1);
}
},
},
{
label: 'Stay hidden', run: (ctx) => {
ctx.say('You let the armor pass unseen. Safer. The word "rescue" sits in the air for a while after the engines fade.');
ctx.flag('militarySeen', 1);
},
},
],
},
{
id: 'beat_gang_ultimatum', title: 'The Red Hand', tag: 'faction', weight: 0, music: 'tense',
text: () => `They arrive at noon with deliberate slowness: eight armed people under a red hand painted on sheet metal. Their leader doesn't threaten. He itemizes.\n\n"Ten food. Or we take the whole pantry, and whatever else looks light. You have until tomorrow's dark."`,
choices: [
{
label: 'Pay the tribute', tip: '-8 food.',
req: { res: { food: 8 } },
run: (ctx) => {
ctx.lose('food', 8); ctx.flag('raider_paid', 1); ctx.flag('karma', -1);
ctx.say('You stack the crates inside the fence line like paying rent. The Red Hand carries them off without a thank-you. Humiliation is cheaper than blood. Usually.');
ctx.morale('all', -6);
},
},
{
label: 'Refuse and fortify',
run: (ctx) => {
ctx.flag('raider_war', 1);
ctx.say('"Tomorrow\'s dark, then," the leader says, almost sadly. You spend the daylight hammering, digging, counting rounds.');
ctx.morale('all', 2);
ctx.queueEvent('raider_assault', 1);
},
},
{
label: 'Attack them right now, while they talk',
run: (ctx) => {
ctx.flag('raider_war', 1);
const party = ctx.s.survivors.filter(A).slice(0, 3);
ctx.encounter({
enemyName: 'The Red Hand', enemyDesc: 'Eight armed raiders who expected obedience, met resistance.',
strength: 52, perception: 45, speed: 45, greed: 15,
loot: [{ res: 'ammo', min: 3, max: 8, weight: 3 }, { res: 'food', min: 2, max: 5, weight: 2 }, { res: 'medicine', min: 1, max: 3, weight: 1 }],
party: party.map(p => p.id),
intro: 'Surprise is yours. It lasts four seconds and is worth a lot.', context: 'event',
});
ctx.queueEvent('raider_aftermath', 2);
},
},
],
},
{
id: 'raider_assault', title: 'Night of the Red Hand', tag: 'faction', weight: 0, music: 'tense',
text: () => `They come at 3 a.m., quiet as weather. The first sign is the dog standing up. The second is the wire going down.\n\nEveryone to positions. This is what the fence was for.`,
choices: [{
label: 'Defend the camp',
run: (ctx) => {
const party = ctx.s.survivors.filter(A).slice(0, 3);
ctx.encounter({
enemyName: 'Red Hand Raiders', enemyDesc: 'They came to take everything. They did not come to die for it.',
strength: 48, perception: 40, speed: 45, greed: 12,
loot: [{ res: 'ammo', min: 2, max: 6, weight: 3 }, { res: 'fuel', min: 1, max: 4, weight: 1 }, { res: 'food', min: 1, max: 4, weight: 2 }],
party: party.map(p => p.id), intro: 'Muzzle flash answers muzzle flash across the yard.', context: 'camp',
});
ctx.queueEvent('raider_aftermath', 1);
},
}],
},
{
id: 'raider_aftermath', title: 'After the Red Hand', tag: 'faction', weight: 0,
text: (s) => s.flags.raiders_broken
? `Dawn comes up over scattered red-painted shields and abandoned weapons. The Red Hand broke. Word will spread through the valleys: this camp bites back.`
: `The camp counts itself in the morning light. However last night went, everyone knows the Red Hand doesn't forget — and doesn't forgive.`,
choices: [{
label: 'Continue',
run: (ctx) => {
if (ctx.s.flags.raiders_broken) {
ctx.morale('all', 10); ctx.flag('hope', 2); ctx.flag('karma', 1);
ctx.flag('reputation', 2);
const t = ctx.rng.pick(alive(ctx.s));
if (t) ctx.memory(t, 'We stood off the Red Hand. We are still here.', 'good');
} else {
ctx.morale('all', -4);
ctx.say('You bury what needs burying and reinforce what needs reinforcing. Somewhere out there, men with red paint are telling their own version of last night.');
}
},
}],
},
{
id: 'beat_sickness_wave', title: 'Something Going Around', tag: 'medical', weight: 0,
text: (s) => `It starts with sneezing and ends with names. Half the camp is feverish within two days — heads pounding, lungs wet. ${s.res.medicine > 3 ? 'The medicine locker suddenly looks small.' : 'There is almost no medicine left to argue about.'}`,
choices: [
{
label: 'Spend medicine on the worst cases', tip: '-3 medicine.',
req: { res: { medicine: 3 } },
run: (ctx) => {
ctx.lose('medicine', 3);
for (const t of alive(ctx.s)) { if (t.hp < 70 || t.sick) { t.sick = false; t.hp = Math.min(100, t.hp + 15); } }
ctx.say('Antibiotics rationed by severity, not by friendship — the doctor insists, loudly, twice. It holds. The fevers break within the week.');
ctx.morale('all', 5);
},
},
{
label: 'Quarantine and soup', run: (ctx) => {
const sick = alive(ctx.s).filter(t => t.hp < 75 || t.sick);
for (const t of sick.slice(0, 2)) {
if (ctx.rng.chance(0.5)) { t.sick = false; ctx.say(`${t.name} sweats it out and lives.`); }
else { ctx.hp(t, -15); t.sick = true; ctx.say(`${t.name} goes downhill despite everything. The camp watches and boils water.`); }
}
if (!sick.length) ctx.say('Somehow, nobody serious catches it. Small mercies get logged too.');
},
},
],
},
{
id: 'beat_halfway', title: 'Day Fifty', tag: 'story', weight: 0, music: 'hopeful',
text: (s) => `Halfway.\n\nFifty days ago the word "survive" meant one more night. Now it means water schedules, watch rotations, a garden with actual rows. Look at what stubbornness built in the ruins of everything.\n\n${alive(s).length} souls. ${Math.floor((s.res.food))} food put away. The dead remembered. The road ahead unknown.`,
choices: [{
label: 'Keep going',
run: (ctx) => {
ctx.morale('all', 6); ctx.flag('hope', 1);
const t = bestAt(ctx.s, 'charisma');
ctx.memory(t, 'Halfway day. We counted our dead, then counted our cans, and kept both lists.', 'neutral');
ctx.say('You read the names of the dead, then the inventory of the living. Both lists matter. Only one gets longer from here.');
},
}],
},
{
id: 'beat_prisoners', title: 'Two Prisoners', tag: 'moral', weight: 0, music: 'dark',
text: () => `One raider died in the night attack. Two didn't — a scarred older man and a kid who can't be more than nineteen, both zip-tied to the fence posts since dawn, both watching you with different kinds of fear.\n\nThe camp is waiting for your decision like it's weather.`,
choices: [
{
label: 'Feed them and release them', tip: '-2 food. Karma.',
req: { res: { food: 2 } },
run: (ctx) => {
ctx.lose('food', 2); ctx.flag('karma', 3); ctx.flag('mercy_shown', 1);
ctx.morale('all', -2);
ctx.say('You cut them loose with a day\'s rations. The kid cries. The scarred man says nothing, but he looks back once from the tree line — a look with arithmetic in it. Mercy is a debt someone now owes you, or thinks they hate owing.');
},
},
{
label: 'Put them to work, then release', run: (ctx) => {
ctx.flag('karma', 1);
ctx.gain('materials', 5);
ctx.say('Three days hauling rubble under guard, watched by everyone. On the fourth morning they walk out stiff and quieter. The camp feels stronger. And slightly harder.');
ctx.morale('all', 2);
},
},
{
label: 'Drive them out with nothing', run: (ctx) => {
ctx.flag('karma', -2);
ctx.morale('all', -4);
ctx.say('You march them past the fence at gunpoint and turn them loose into the gray. Nobody cheers. Justice done cheap still costs something.');
ctx.queueEvent('prisoner_return', ctx.rng.i(15, 30), { name: 'The young raider' });
},
},
],
},
{
id: 'prisoner_return', title: 'One Came Back', tag: 'moral', weight: 0, music: 'tense',
text: (_s, cast) => `${cast.extras[0] ?? 'The prisoner'} returns alone at dusk, hands empty and raised. Behind the fear there's something steadier.\n\n"The crew I ran with — they're done hiding. They'll hit every camp on this road, including yours. I figured a warning buys my bed back."`,
choices: [
{
label: 'Take the warning. Let him stay.',
run: (ctx) => {
const j = ctx.joinGroup({ name: ctx.cast.extras[0] === 'The young raider' ? 'Rafa' : undefined });
if (j) { ctx.flag('karma', 2); ctx.flag('raid_warning', 1); ctx.say(`${j.name} sleeps inside the wire tonight. The warning is real — scouts confirm movement within the week.`); }
},
},
{
label: 'Take the warning. Send him on.',
run: (ctx) => { ctx.flag('raid_warning', 1); ctx.flag('karma', 1); ctx.say('You listen to every detail, mark the routes, and give him a canteen for the road. He nods like a soldier dismissed.'); },
},
{
label: 'Turn him away',
run: (ctx) => { ctx.say('He accepts it the way soldiers accept weather. The warning was free. You\'ll wonder what else was.'); ctx.flag('raid_warning', 1); ctx.morale('all', -2); },
},
],
},
{
id: 'beat_herd_year', title: 'The Long Gray River', tag: 'horror', weight: 0, music: 'dark',
text: () => `The scout comes in white-faced and talking fast: a mega-herd of the dust-sick, thousands strong, is turning up the valley toward the highway. Toward everything.\n\nIt will pass the camp sometime tomorrow. You have one night to decide how to be invisible.`,
choices: [
{
label: 'Full blackout. Bury the fires.',
run: (ctx) => {
ctx.say('Cold food, whispered orders, twenty hours of listening to a city-sized crowd of the dead shuffle past in the dark. At some point a hand brushes the tent wall. Nobody screams. Nobody even breathes loud.');
ctx.morale('all', -5);
ctx.flag('herd_survived', 1);
ctx.flag('hope', -1);
},
},
{
label: 'Burn a diversion line east', tip: '-3 fuel.',
req: { res: { fuel: 3 } },
run: (ctx) => {
ctx.lose('fuel', 3);
ctx.say('A quarter mile of brush fire bends the river of the dead away from your valley. From the tower you watch the herd flow around your light like water around a stone. Expensive. Worth it.');
ctx.flag('herd_diverted', 1);
ctx.flag('reputation', 1);
},
},
],
},
{
id: 'beat_the_truth', title: 'What the Radio Said', tag: 'story', weight: 0, music: 'sad',
text: () => `At 21:00 exactly, the frequency from Day 6 crackles into a human voice reading from paper — tired, official, finished lying.\n\n"…this is UNCOR Southern Command, final broadcast. In March, an orbital research platform — Site Grey — suffered cascade failure during a weaponized bio-shield test. The fall-out is a self-replicating agricultural blight bound in atmospheric dust. We named it Grayfall because naming things is the last authority we have.\n\nIt did not kill us quickly, which is how we know it wasn't meant to. Crops failed worldwide in six weeks. The sick you see breathing dust are suffering engineered protein collapse. There is no cure. There is no quarantine that holds.\n\nSouthern islands confirmed clean. Convoy Gamma runs the coastal road until Day 100. After Day 100 we are ordered home. God keep the rest of you.\n\nThis is UNCOR Southern Command, signing off."`,
choices: [
{
label: 'Listen to every word',
run: (ctx) => {
ctx.flag('heardTruth', 1);
ctx.flag('hope', 1);
ctx.say('The static afterward sounds like the universe shrugging. People repeat pieces of it all night — "weaponized", "until Day 100", "islands". The countdown everyone felt finally has edges.');
ctx.morale('all', 3);
const t = bestAt(ctx.s, 'charisma');
if (t) ctx.memory(t, 'We heard the truth about Grayfall on the radio. Now we know the deadline is real.', 'neutral');
},
},
],
},
{
id: 'beat_truth_static', title: 'Eleven Seconds', tag: 'story', weight: 0,
text: () => `On the old frequency, at 21:00, there is almost-speech buried under static — syllables with the shape of numbers, a voice like a signal flare seen from underwater.\n\nWithout power to pull the signal clean, it stays a rumor. Someone swears they heard "Day 100."`,
choices: [{
label: 'Mark the date',
run: (ctx) => { ctx.flag('hintedTruth', 1); ctx.say('You chalk DAY 100? on the shelter wall. Whatever it means, it\'s coming at a known speed.'); },
}],
},
{
id: 'beat_convoy_offer', title: 'The Window Opens', tag: 'story', weight: 0, music: 'hopeful',
text: () => `The card from the convoy hasn't faded, and tonight the frequency on it wakes up. A real voice, using your location tag.\n\n"Civilian station — confirm you are receiving. Convoy Gamma extraction window: Day 100, coastal road muster point. We take walking wounded, supplies limited to one crate per soul. Confirm intent."\n\nThe whole camp is standing behind you, listening to a machine say the word rescue.`,
choices: [
{
label: 'Confirm. We\'ll be there.', tip: 'Commit to evacuation prep.',
run: (ctx) => {
ctx.flag('evacPrep', 1); ctx.flag('hope', 2);
ctx.say('"Intent confirmed. Muster point marked. Bring strong legs," the voice says, and signs off. Twenty-three days to pack a life into one crate per soul.');
ctx.morale('all', 8);
const t = ctx.rng.pick(alive(ctx.s)); if (t) ctx.memory(t, 'The military called us back. Day 100, they come for us.', 'good');
},
},
{
label: 'Decline — this ground is ours now',
run: (ctx) => {
ctx.flag('stayed_ground', 1);
ctx.say('"Understood. God keep you," the voice says, meaning it professionally, and the frequency closes forever.');
ctx.morale('all', -3);
},
},
],
},
{
id: 'beat_no_contact', title: 'Quiet on All Bands', tag: 'story', weight: 0,
text: () => `The radio stays dead all night. Whoever used that frequency is talking to someone else — somewhere greener, probably. The camp pretends not to care with varying degrees of success.`,
choices: [{ label: 'Keep working', run: (ctx) => { ctx.morale('all', -2); } }],
},
{
id: 'beat_exodus_rumor', title: 'Boats', tag: 'story', weight: 0,
text: () => `A raft of rumors drifts in with a half-dead traveler: the southern fishing villages are running boats out through the clean-water corridor. Real boats. Islands on the other side where the dust never fell.\n\n"They leave from Old Harbor," the traveler rasps. "Last runs around Day 100. After that, the sea decides."`,
choices: [{
label: 'Ask about the route',
run: (ctx) => {
ctx.flag('boatsRumor', 1); ctx.flag('hope', 1);
ctx.say('Coastal road, three days\' drive — faster with a working vehicle and fuel to feed it. The traveler sketches the harbor from memory, tides and all, then dies polite in the guest bunk. You bury him facing south.');
ctx.discoverRandom();
},
}],
},
{
id: 'beat_last_warning', title: 'The Sky Is Wrong Again', tag: 'weather', weight: 0, music: 'dark',
text: () => `The pressure drops for two days straight. Old bones ache. The birds shut up completely.\n\nEvery survivor in camp reads the sky the same way: the biggest storm season in memory is loading up, and Day 100 is arriving right in the middle of it.`,
choices: [
{
label: 'Reinforce everything', tip: '-6 materials.',
req: { res: { materials: 6 } },
run: (ctx) => {
ctx.lose('materials', 6);
ctx.s.camp.integrity = 100;
ctx.say('Double guy-wires, sandbags on the roof edges, the garden under canvas. When the sky finally loses its temper, the camp will have opinions of its own.');
ctx.morale('all', 4);
},
},
{
label: 'Stock what matters and hope',
run: (ctx) => {
ctx.say('You move everything edible to interior shelves and nail the windows that rattle. Hope is a material too, if you have enough of it.');
ctx.flag('hope', 1);
},
},
],
},
{
id: 'beat_last_prep', title: 'Five Days Out', tag: 'story', weight: 0, music: 'hopeful',
text: (s) => `Five days.\n\nWhatever Day 100 brings — trucks, boats, storms, nothing at all — the shape of it is fixed now. The camp talks logistics in the daytime and, more quietly, futures after dark.${(s.flags.evacPrep) ? '\n\nThe muster plan is drawn on the wall in charcoal: who carries what, who walks with whom.' : ''}${(s.flags.boatsRumor) ? '\n\nSomeone has started oiling the axles "just in case."' : ''}`,
choices: [{
label: 'See it through',
run: (ctx) => { ctx.morale('all', 5); ctx.flag('hope', 1); ctx.say('Final inventories. Final drills. Five days is nothing. Five days is everything.'); },
}],
},
{
id: 'beat_last_supper', title: 'The Last Ordinary Night', tag: 'story', weight: 0, music: 'sad',
text: (s) => {
const names = alive(s).map(x => x.name).join(', ');
return `Day 99 ends with a fire bigger than rationing allows, and nobody objects.\n\n${names} — everyone still here — sits in the light. Letters get written and folded into pockets. Arguments get settled on purpose, because tomorrow is a bad day to leave things broken.\n\nWhatever happens at sunrise, this circle existed. That's not nothing. It was never nothing.`;
},
choices: [
{
label: 'Say the words that matter',
run: (ctx) => {
ctx.morale('all', 10); ctx.flag('hope', 2);
for (const t of alive(ctx.s)) {
const friend = ctx.s.survivors.find(x => x.id !== t.id && A(x));
if (friend) ctx.rel(t.id, friend.id, 5);
ctx.memory(t, 'The last night. We said the true things out loud.', 'good');
}
ctx.say('Around a fire at the edge of the world, people say thank you, and I\'m sorry, and remember when. The stars come out through thinner ash than usual, like even the sky is trying.');
},
},
],
},
{
id: 'beat_quiet_day', title: 'A Quiet Day', tag: 'story', weight: 0,
text: () => `Nothing happens. Genuinely nothing — no strangers, no shots in the distance, no weather with opinions. The camp does its chores and, cautiously, enjoys it.`,
choices: [{ label: 'Enjoy it', run: (ctx) => { ctx.morale('all', 3); ctx.say('By evening people are almost relaxed, which is its own strange feeling, like a language you used to speak.'); } }],
},
/* ============================================================
DAY 100 — THE FINALE
============================================================ */
{
id: 'finale_day', title: 'DAY 100', tag: 'finale', weight: 0, music: 'tense',
text: (s) => {
const n = alive(s).length;
const base = `The day the whole world was counting toward arrives gray and cold, with the storm front stacked on the horizon like a verdict.\n\n${n} ${n === 1 ? 'soul walks' : 'souls walk'} out to the fence line at dawn and look at the road together.\n\n`;
const opts: string[] = [];
if (s.flags.heardTruth) opts.push('UNCOR said Convoy Gamma runs until today. After today, never again.');
if (s.flags.boatsRumor) opts.push('Old Harbor\'s last boats leave through the clean corridor today.');
if (!opts.length) opts.push('No promises were ever made to you. Whatever happens next, you decide it.');
return base + opts.join('\n\n') + '\n\nSo. Where does the story end?';
},
choices: [
{
label: 'Muster for the convoy', tip: 'Needs evacuation plans agreed with UNCOR.',
req: { flag: 'evacPrep' },
run: (ctx) => {
ctx.flag('finale_path', 1);
ctx.say('You strike the camp at first light and walk the coastal road under gray skies, one crate per soul, hearts going like drums.');
},
},
{
label: 'Run for Old Harbor', tip: 'Needs a vehicle and fuel.',
req: { flag: 'vehicle' , res: { fuel: 8 } },
run: (ctx) => {
ctx.flag('finale_path', 2);
ctx.lose('fuel', 8);
ctx.say('The engine catches on the third try — of course it does — and the camp becomes a cloud of dust in the mirrors. Three days to the sea.');
},
},
{
label: 'Hold the ground — this is home now',
run: (ctx) => {
ctx.flag('finale_path', 3);
ctx.say('If the world wants this valley, it can knock. The last storm of the hundred days rolls in at dusk — and with it, shapes on the road. Raiders, desperate and many, come for full pantries before winter.');
const party = ctx.s.survivors.filter(A).slice(0, 4);
ctx.encounter({
enemyName: 'The Last Raid', enemyDesc: 'Everyone hungry in a fifty-mile radius, all at once.',
strength: 62, perception: 45, speed: 45, greed: 20,
loot: [{ res: 'ammo', min: 3, max: 8, weight: 2 }, { res: 'food', min: 4, max: 9, weight: 2 }, { res: 'materials', min: 3, max: 7, weight: 2 }],
party: party.map(p => p.id), intro: 'They hit the west fence in the rain, all of them at once.', context: 'camp',
});
ctx.flag('hold_fight_done', 1);
},
},
{
label: 'Stay. Whatever comes, come what may.',
run: (ctx) => {
ctx.flag('finale_path', 5);
ctx.say('No trucks were promised to you. No boats wait at any harbor. So you bar the gate a little tighter, bank the fires, and let Day 100 arrive on its own terms — the way your people have met every other day.');
},
},
{
label: 'Light the Beacon', tip: 'Requires: 3 radio parts • seed vault • generator • real hope.',
req: {
minFlag: { key: 'radio_parts', min: 3 },
flag: 'seed_vault',
res: {},
},
run: (ctx) => {
if (!ctx.s.flags.bldg_generator || (ctx.s.flags.hope ?? 0) < 2) {
ctx.say('You carry the parts to the rig, wire them together — and the camp looks at you like you\'ve proposed prayer in a language nobody speaks. Without power and without belief, the message dies in the wires.');
ctx.flag('finale_failed_beacon', 1);
ctx.flag('finale_path', 5); // no viable path → wanderers
return;
}
ctx.flag('finale_path', 4);
ctx.say('You assemble the three recovered radio parts onto the generator rig, key the mic, and send the message the fragments taught you: coordinates, seed vault contents, headcount, and one sentence of your own.\n\n"This is Station Last Light. We kept the lights on. We kept the seeds. Come and see."');
},
},
],
},
{
id: 'finale_storm_night', title: 'Storm Over Everything', tag: 'finale', weight: 0, music: 'sad',
text: () => `Whatever else the last day holds, the storm arrives first — the big one, the one the birds went silent about.\n\nThe camp holds its hat and waits for history.`,
choices: [{ label: 'Face it', run: () => {} }],
},
];
/* ============================================================
ENDINGS
============================================================ */
export interface EndingDef {
id: string;
title: string;
tone: 'good' | 'bitter' | 'bad' | 'transcendent';
body: string;
}
export const ENDINGS: Record<string, EndingDef> = {
silence: {
id: 'silence', title: 'SILENCE', tone: 'bad',
body: `The camp goes quiet one day at a time, then all at once. Wind moves through the shelters. The garden goes to seed, honestly, having tried. Rain gets into the pages of the log book and blurs the names into rivers.\n\nYears later, someone walking the valley finds the fence, the graves, the chalk on the wall: a hundred-day record of stubborn love at the end of the world.\n\nThey stand there a while. Then they keep walking.`,
},
wanderers: {
id: 'wanderers', title: 'THE LONG ROAD', tone: 'bitter',
body: `The plan dies somewhere on the road — the fuel, the boat, the timetable, all of it — and what remains is smaller than hope but tougher: people, walking, together.\n\nThey outlast the storm season. They outlast the rumor of rescue. They become the rumor — a band that moves through the valleys trading work for food, stories for stories, carrying the memory of a camp that held for a hundred days.\n\nNot saved. Not beaten. Still here.`,
},
coastal_escape: {
id: 'coastal_escape', title: 'THE CLEAN SHORE', tone: 'good',
body: `Old Harbor at dawn, engines turning over through the mist. The boatman counts heads twice, shakes his own, and waves you aboard anyway.\n\nThe mainland shrinks into ash-gray haze, and then the rain stops, actually stops, and ahead the water is the wrong color in the best way: blue. On deck, someone starts laughing and can't explain it.\n\nBehind you: a hundred days, a valley of graves and gardens, every name carried aboard like luggage. Ahead: islands where the dust never fell. The children born there will learn the word "before" from your stories.`,
},
military_evacuation: {
id: 'military_evacuation', title: 'CONVOY GAMMA', tone: 'good',
body: `They come exactly when promised, which after everything feels like a miracle with paperwork. Decontamination spray, hot rations, blankets stamped with an organization that technically still exists.\n\nThe southern base receives your people the way a shore receives a boat: names taken, wounds dressed, questions gentle and endless. There will be forms. There will be quarantines. None of it matters, because tonight everyone sleeps behind wire that guards instead of traps.\n\nDay 100. Extracted. Alive.`,
},
rescue: {
id: 'rescue', title: 'WHEN THE LIGHTS STAYED ON', tone: 'good',
body: `The helicopters find you by your light — exactly as instructed, all those nights of keeping the generator fed finally meaning something. Ropes drop through rotor wash. Medics ride down.\n\nYour wounded travel strapped and monitored. Your healthy carry strangers' children. A UNCOR colonel reviews your logs — water math, watch rotations, the funeral records — and says quietly: "You ran a better station than most of mine."\n\nThe valley recedes under the skids: gardens, graves, a fence that held. Somebody left a lantern burning in the common room. Tradition, now.`,
},
new_settlement: {
id: 'new_settlement', title: 'STATION LAST LIGHT', tone: 'good',
body: `The raid breaks on your walls like the storm does — furiously, and then not at all. When the rain quits on the afternoon of Day 100, the valley belongs to the living.\n\nNobody comes to save you. That stops being the point somewhere around noon. There is water in the collectors, seed in the lockers, a tally on the wall with honest numbers, and a cemetery kept like a promise.\n\nBy the first snow, travelers know the way: follow the road to the smoke that never quits, the gate that opens, the camp that became a town. The sign above it is hand-painted and load-bearing:\n\nSTATION LAST LIGHT — 100 DAYS AND COUNTING.`,
},
civilization: {
id: 'civilization', title: 'FIRST LIGHT AGAIN', tone: 'transcendent',
body: `The reply comes at midnight, typed in the old UNCOR cipher your salvaged radio parts were built to speak:\n\n"STATION LAST LIGHT — AUTHENTICATED. Seed viability confirmed. You are designated First Cultivation Site, Southern Reclamation. Ships depart within the month. Hold your position. Grow everything."\n\nShips. plural. The word travels through camp like warmth through a coat.\n\nOn Day 100, while the last storm spends itself against the hills, your people plant the vault wheat in long rows under gray light — the first deliberate agriculture in the valley since the world ended. It isn't rescue. It's better. It's a job description for the species.\n\nSomewhere inland, the dust still falls. Here, for the first time in a hundred days, something in the ground is answering.`,
},
sacrifice: {
id: 'sacrifice', title: 'THE ONE WHO STAYED', tone: 'bitter',
body: `It costs one life to buy the rest, and everyone knows whose. They tell the story wrong later — cleaner, braver — but the truth is enough: on the last day, with the raid in the wire and the weak in the cellar, someone went out and closed the door.\n\nThe others live. That was the entire plan, written in the moment, executed perfectly.\n\nThey bury the hero on the hill above the garden, facing the road in. The marker is a rifle, a canteen, and words cut deep enough to outlast paint:\n\nHERE STOOD A PERSON WHO COUNTED EVERYONE.\n\nEvery year on Day 100, the settlement dims its lamps at dusk — except one.`,
},
};
+484
View File
@@ -0,0 +1,484 @@
import type { EventDef } from '../../engine/types';
import { hasTrait, skill } from '../survivors';
const A = (s: import('../../engine/types').Survivor) => s.alive && !s.gone;
export const WORLD_EVENTS: EventDef[] = [
/* ------------------------------------------------ STORM DAMAGE */
{
id: 'storm_damage', title: 'The Sky Comes Down', tag: 'weather', weight: 0,
text: () => `The storm rips a sheet of roofing off the common shelter and drives rain sideways through everything you own. Water pours through the food stores.`,
choices: [
{
label: 'Patch it now, in the storm', tip: 'Costs 4 materials. Someone could get hurt.',
req: { res: { materials: 4 } },
run: (ctx) => {
ctx.lose('materials', 4);
const worker = ctx.rng.pick(ctx.s.survivors.filter(A));
if (worker && ctx.rng.chance(0.25)) {
ctx.hurt(worker, 'cut', 'Deep gash from sheet metal', 2, 5);
ctx.say(`${worker.name} gets the patch down and pays for it in blood — a flap of roofing opens their arm to the bone.`);
ctx.memory(worker, 'Held the roof down with my body during the storm.', 'bad');
} else if (worker) {
ctx.say(`${worker.name} lashes the sheeting down bare-handed while the world tries to take it away. By dawn, the camp is dry.`);
}
},
},
{
label: 'Wait it out', run: (ctx) => {
const lostFood = Math.min(3 + Math.floor(ctx.rng.i(0, 3)), ctx.s.res.food);
ctx.lose('food', lostFood);
ctx.s.camp.integrity = Math.max(10, ctx.s.camp.integrity - 12);
ctx.say(`By the time the wind quits, ${lostFood} food is spoiled mush and half the camp is sleeping under a tarp. Integrity suffers.`);
ctx.morale('all', -5);
},
},
],
},
/* ------------------------------------------------ WATER TROUBLE */
{
id: 'water_fouled', title: 'Something in the Water', tag: 'weather', weight: 9, minDay: 5,
text: () => `The collector tarps have caught a fine gray film overnight. The water underneath tastes like pennies and regret.`,
choices: [
{
label: 'Boil it all', tip: 'Costs 1 fuel.',
req: { res: { fuel: 1 } },
run: (ctx) => { ctx.lose('fuel', 1); ctx.say('A full day of boiling. The fire eats fuel but the water comes out clean, or clean enough.'); },
},
{
label: 'Filter through cloth and ash', run: (ctx) => {
if (ctx.rng.chance(0.6)) ctx.say('Old trick, good trick. The ash catches what the cloth doesn\'t. Nobody gets sick.');
else {
const t = weakest(ctx.s);
t.sick = true;
ctx.say(`${t.name} swears it tastes fine. Two days later ${t.name} is the color of porridge and everyone else is boiling everything.`);
ctx.morale('all', -3);
}
},
},
{
label: 'Dump it', run: (ctx) => {
const w = Math.min(5, ctx.s.res.water); ctx.lose('water', w);
ctx.say(`You dump ${w} water onto the ground and watch it disappear. Thirst is a patient teacher.`);
},
},
],
},
/* ------------------------------------------------ DOGS */
{
id: 'dogs_stalk', title: 'Eyes at the Treeline', tag: 'animal', weight: 11, minDay: 3,
text: () => `They've been circling for two nights — a pack of feral dogs, ribs like fence slats, working up the nerve. Today someone found paw prints inside the fence line.`,
choices: [
{
label: 'Drive them off with fire and noise', tip: 'Costs 1 fuel.',
req: { res: { fuel: 1 } },
run: (ctx) => {
ctx.lose('fuel', 1);
ctx.say('Burning brush and banging pots. The pack melts into the trees, offended but convinced.');
},
},
{
label: 'Fight them', run: (ctx) => {
const party = ctx.s.survivors.filter(A).slice(0, 2);
ctx.encounter({
enemyName: 'Feral Dogs', enemyDesc: 'Fast, silent, starving.',
strength: 18, perception: 55, speed: 70, greed: 0,
loot: [{ res: 'food', min: 2, max: 3, weight: 1 }],
party: party.map(p => p.id), intro: 'The pack attacks in a crescent — fast ones first.', context: 'camp',
});
},
},
{
label: 'Leave food out for them', tip: '-2 food. Who knows.',
req: { res: { food: 2 } },
run: (ctx) => {
ctx.lose('food', 2);
if (ctx.rng.chance(0.5)) {
ctx.say('In the morning the food is gone and so are the dogs. But one of them — a gray bitch with a torn ear — stays. She watches the fence like she\'s applied for a job.');
ctx.flag('camp_dog', 1); ctx.morale('all', 6);
} else {
ctx.say('The food vanishes. The circling continues. You have fed the problem.');
}
},
},
],
},
/* ------------------------------------------------ FERAL AT FENCE */
{
id: 'feral_night', title: 'Gray Shapes', tag: 'horror', weight: 10, minDay: 8, music: 'dark',
text: () => `A watchman's flashlight finds them at the fence: three figures pressed against the wire, faces gray as wet paper, breathing the dust with every rattle of the chain-link.\n\nThe dust-sick. They don't climb. They just... push.`,
choices: [
{
label: 'Put them down quietly', tip: 'Uses 1 ammo, or steel at close range.',
run: (ctx) => {
if (ctx.hasRes('ammo', 1)) {
ctx.lose('ammo', 1);
ctx.say('Three shots, spaced like heartbeats. Nobody talks over breakfast, but everybody sleeps behind a solid door tonight.');
ctx.flag('karma', 0);
} else {
const fighter = bestAt(ctx.s, 'combat');
if (skill(fighter, 'combat') >= 5) {
ctx.say(`${fighter.name} does it with the crowbar, methodical as chopping wood, and comes back shaking anyway.`);
ctx.morale(fighter, -6);
ctx.memory(fighter, 'Had to use the crowbar on the dust-sick. I still feel it in my arms.', 'bad');
} else {
ctx.hp(fighter, -12);
ctx.hurt(fighter, 'bite', 'Grazed by grasping teeth', 1, 4);
ctx.say(`${fighter.name} gets it done, but not cleanly. Something's torn on their forearm, and everyone knows what bites mean around here.`);
ctx.morale('all', -4);
}
}
},
},
{
label: 'Lead them away with noise', tip: 'Risky without a watchtower.',
run: (ctx) => {
if ((ctx.s.camp.buildings.watchtower ?? 0) >= 1) {
ctx.say('From the tower you can angle their path with a lantern and a thrown bottle. They shamble off toward the highway, pushing air.');
} else if (ctx.rng.chance(0.5)) {
ctx.say('It works, barely. Banging a pot along the fence, walking backwards for an hour. Your nerves are shot but the fence holds.');
} else {
ctx.say('One of them turns wrong — faster than the others — and gets inside the wire for eleven horrible seconds.');
const victim = ctx.rng.pick(ctx.s.survivors.filter(A));
ctx.hurt(victim, 'bite', 'Torn shoulder', 2, 6);
ctx.morale('all', -5);
}
},
},
],
},
/* ------------------------------------------------ HERD */
{
id: 'herd_passing', title: 'The Migration', tag: 'horror', weight: 7, minDay: 15, music: 'dark',
text: () => `First one shape on the road. Then ten. Then a column of the dust-sick a hundred wide, flowing past your valley like a slow gray river.\n\nThey will pass within sight of camp for most of a day. Everything about surviving tonight depends on being invisible — or being loud enough to matter.`,
choices: [
{
label: 'Blackout and silence', run: (ctx) => {
ctx.say('No fires. No voices. The camp breathes in unison for eight hours while the dead-end parade drags past. It works. Everyone cries a little, privately.');
ctx.morale('all', -6);
ctx.flag('hope', -1);
},
},
{
label: 'Divert them with a burning ditch', tip: 'Costs 2 fuel.',
req: { res: { fuel: 2 } },
run: (ctx) => {
ctx.lose('fuel', 2);
ctx.say('A trench of flame bends the herd south, away from the fields. It also lights your position for anyone watching. Worth it. Probably.');
ctx.flag('karma', 1);
},
},
{
label: 'Cull the edges from the tower', tip: 'Needs Watchtower. Uses ammo.',
req: { flag: 'bldg_watchtower' },
run: (ctx) => {
const cost = Math.min(3, ctx.s.res.ammo);
ctx.lose('ammo', cost);
const gain = ctx.rng.i(2, 5);
ctx.gain('materials', 0); // no loot; morale effect below
ctx.morale('all', 3);
ctx.say(`Controlled, disciplined, awful work. ${cost} rounds later the herd's edge thins and flows around the valley. The camp feels — briefly — like masters of something.`);
},
},
],
},
/* ------------------------------------------------ RUIN COLLAPSE */
{
id: 'ruin_collapse', title: 'The Floor Gives Way', tag: 'danger', weight: 10,
text: () => `Halfway through hauling salvage, the floor answers a footstep with a groan and then with nothing at all — a whole section of joists folding into the basement dark.`,
choices: [
{
label: 'Grab what you can and dive clear', run: (ctx) => {
const party = ctx.cast.extras.map(id => ctx.s.survivors.find(x => x.id === id)!).filter(Boolean);
const victim = ctx.rng.pick(party.length ? party : ctx.s.survivors.filter(A));
if (hasTrait(victim, 'sharpEye') || skill(victim, 'scavenge') >= 6) {
ctx.say(`${victim.name} reads the sag a half-second early and hauls everyone back. Dust, screaming hinges, no blood.`);
} else {
ctx.hurt(victim, 'fracture', 'Leg broken in the collapse', 3, 12);
ctx.say(`The floor takes ${victim.name}'s leg between joists. The scream is still ringing in everyone's ears when they dig them out.`);
ctx.morale('all', -3);
}
const salvaged = ctx.rng.i(1, 3);
ctx.gain('materials', salvaged);
ctx.say(`Some salvage makes it out: ${salvaged} materials hauled from the lip of the pit.`);
},
},
],
},
/* ------------------------------------------------ GAS / FIRE */
{
id: 'fuel_fire', title: 'Vapor', tag: 'danger', weight: 8,
text: () => `Siphoning goes wrong. Fuel vapor finds the lantern flame before anyone's brain finishes the sentence "wait, don't—". A blue cough of fire rolls across the ground.`,
choices: [
{
label: 'Smother it with dirt', run: (ctx) => {
const eng = bestAt(ctx.s, 'engineering');
if (skill(eng, 'engineering') >= 4 || ctx.rng.chance(0.5)) {
ctx.say(`${eng.name} buries the flame line in three shovelfuls flat. Burned eyebrows, nothing worse.`);
} else {
const v = ctx.rng.pick([eng, ...ctx.s.survivors.filter(A)]);
ctx.hurt(v, 'burn', 'Flash burns across the hands', 2, 8);
ctx.lose('fuel', Math.min(3, ctx.s.res.fuel));
ctx.say(`The flame jumps its lane. ${v.name} takes flash burns across both hands, and three fuel go up in a bad smell.`);
}
},
},
{
label: 'Let it burn out', run: (ctx) => {
const f = Math.min(4, ctx.s.res.fuel); ctx.lose('fuel', f);
ctx.say(`You stand back and let chemistry finish its sentence. ${f} fuel gone in blue-orange light.`);
},
},
],
},
/* ------------------------------------------------ DRONE */
{
id: 'drone_overhead', title: 'The Eye Passes Over', tag: 'military', weight: 8, minDay: 20,
text: () => `A flat mechanical buzz crosses the sky — a military drone, military green, moving with intent. It circles once above the camp.\n\nWhatever it wants to know, it knows now. Unless you make yourself interesting.`,
choices: [
{
label: 'Signal it with a mirror', run: (ctx) => {
ctx.flag('militaryRep', 2);
ctx.say('Three flashes. The drone banks and is gone. Two days later a supply crate drops by parachute two fields east: medicine, ammunition, and a typed note. "KEEP THE LIGHTS ON."');
ctx.queueEvent('supply_drop', 2);
},
},
{
label: 'Hide and stay small', run: (ctx) => { ctx.say('Everyone under tarps until the buzz fades. Whatever it counted, you weren\'t part of the number.'); ctx.flag('militaryRep', 1); },
},
{
label: 'Shoot it down', tip: 'Costs 2 ammo. Parts, and consequences.',
req: { res: { ammo: 2 } },
run: (ctx) => {
ctx.lose('ammo', 2);
if (ctx.rng.chance(0.6)) {
ctx.gain('tools', 1); ctx.gain('materials', 4);
ctx.flag('militaryRep', -3);
ctx.say('It spirals down trailing smoke. The wreck yields servos and a camera core worth salvaging — and somewhere, someone has logged the gunshot.');
} else {
ctx.flag('militaryRep', -3);
ctx.say('You empty the sky around it and miss every time. The drone completes its orbit almost politely, then leaves.');
}
},
},
],
},
{
id: 'supply_drop', title: 'The Crate', tag: 'military', weight: 0,
text: () => `The parachute is already collapsed by the time you reach the field. The crate sits there like a promise kept by a stranger.`,
choices: [
{ label: 'Open it', run: (ctx) => { ctx.gain('medicine', 3); ctx.gain('ammo', 3); ctx.gain('food', 3); ctx.morale('all', 6); ctx.flag('hope', 1); ctx.say('Medicine. Ammunition. Real coffee, of all things. The note inside repeats: KEEP THE LIGHTS ON.'); ctx.memory(bestAt(ctx.s, 'charisma'), 'We got a supply drop. Somebody out there is keeping count.', 'good'); } },
],
},
/* ------------------------------------------------ HERMIT */
{
id: 'fog_hermit', title: 'The Voice in the Fog', tag: 'ghost', weight: 8, minDay: 9,
text: () => `Fog sits on the camp like a lid, and out of it comes a voice — steady, old, amused. "You're burning green wood," it says. "Wasteful. Come, I'll trade you proper."`,
choices: [
{
label: 'Follow the voice', run: (ctx) => {
const j = ctx.joinGroup();
if (j) {
j.age = ctx.rng.i(58, 71);
ctx.say(`An old man in a fisherman's coat, living out of a bus shelter with a woodstove. He introduces himself as ${j.name}, offers tea made from pine needles, and talks like the fog gave him permission.`);
ctx.gain('materials', 3);
} else {
ctx.say('The hermit trades you dry cordwood and three honest jokes, and refuses to say where his woodpile is. Trade secrets.');
ctx.gain('materials', 4);
}
},
},
{
label: 'Stay put — fog lies', run: (ctx) => { ctx.say('You bar the door. In the morning there\'s a bundle of dry kindling on your doorstep with a knot tied in the twine. People are strange. Keep going.'); },
},
],
},
/* ------------------------------------------------ FISH RUN */
{
id: 'fish_run', title: 'Silver in the Shallows', tag: 'nature', weight: 8, minDay: 6,
text: () => `Upstream, the water boils silver — a fish run, the first real one since the Collapse. The river carried less poison this week and life noticed before you did.`,
choices: [
{
label: 'Everyone to the water, now', tip: 'Big food, soaked clothes.',
run: (ctx) => {
const f = ctx.rng.i(4, 8);
ctx.gain('food', f);
const hunter = bestAt(ctx.s, 'hunt');
ctx.morale('all', 5);
ctx.say(`${hunter.name} wades out and starts throwing fish onto the bank like a man bailing a sinking boat. ${f} food, laughing, soaked to the bone. For one afternoon the Collapse can wait.`);
ctx.memory(hunter, 'The day of the fish run. We laughed in the water.', 'good');
},
},
{ label: 'Too exposed. Skip it', run: (ctx) => { ctx.say('You watch the silver from the treeline and eat rations instead. Discipline tastes like nothing.'); } },
],
},
/* ------------------------------------------------ OLD CACHE RUMOR */
{
id: 'gov_cache_rumor', title: 'The Government Cache', tag: 'discovery', weight: 7, minDay: 14,
text: () => `A dying scavenger trades a secret for water: before the Collapse, FEMA staged a supply cache in the hills — generators, medical pallets, the works. "Grid reference's true," he wheezes. "Guard's not."`,
choices: [
{
label: 'Mount an expedition', tip: 'Costs 2 fuel. High risk, high reward.',
req: { res: { fuel: 2 } },
run: (ctx) => {
ctx.lose('fuel', 2);
const party = ctx.s.survivors.filter(A).slice(0, 2);
if (ctx.rng.chance(0.45)) {
ctx.encounter({
enemyName: 'Cache Squatters', enemyDesc: 'They found the cache first and call it theirs now.',
strength: 40, perception: 50, speed: 35, greed: 10,
loot: [{ res: 'materials', min: 4, max: 9, weight: 3 }, { res: 'medicine', min: 2, max: 5, weight: 2 }, { res: 'fuel', min: 2, max: 5, weight: 2 }],
party: party.map(p => p.id), intro: 'The cache is exactly where he said. So are the men living in it.', context: 'event',
});
} else {
ctx.gain('materials', ctx.rng.i(6, 12)); ctx.gain('medicine', ctx.rng.i(2, 5)); ctx.gain('fuel', ctx.rng.i(2, 4));
ctx.say('The cache stands open and untouched — the guards died at their posts months ago. You load until the cart axle complains.');
ctx.morale('all', 8); ctx.flag('hope', 1);
}
},
},
{ label: 'Not worth the trip', run: (ctx) => { ctx.say('You give the man the water anyway. He dies polite. The grid reference dies with him.'); ctx.flag('karma', 1); } },
],
},
/* ------------------------------------------------ GARDEN BLIGHT */
{
id: 'garden_blight', title: 'Gray on the Leaves', tag: 'nature', weight: 9,
cond: (s) => (s.camp.buildings.garden ?? 0) > 0,
text: () => `Overnight, a gray powder blooms across half the garden beds — the dust, finding something alive and settling into it like a debt.`,
choices: [
{
label: 'Burn the affected beds', run: (ctx) => {
ctx.say('Acrid smoke, black leaves, hard choices. The blight stops at the fire line. Half the garden lives.');
ctx.flag('garden_hit', 2);
},
},
{
label: 'Risk it — maybe the plants fight', run: (ctx) => {
if (ctx.rng.chance(0.35)) { ctx.say('Somehow they fight it off. Stubborn chlorophyll. The garden survives whole.'); ctx.morale('all', 2); }
else {
ctx.say('Within four days the whole garden looks like it died in its sleep. You lose a week of growth and more than a little hope.');
ctx.flag('garden_hit', 4); ctx.morale('all', -4);
}
},
},
],
},
/* ------------------------------------------------ TREMOR */
{
id: 'tremor', title: 'The Ground Objects', tag: 'danger', weight: 6, minDay: 25,
text: () => `A long rolling shudder passes under the camp — not an earthquake, the engineers say, just the old mine shafts finally letting go, three valleys over. The sound arrives late, like thunder with regrets.`,
choices: [
{
label: 'Inspect everything immediately', tip: 'Materials check.',
req: { res: { materials: 3 } },
run: (ctx) => {
ctx.lose('materials', 3);
ctx.say('Cracked beams sistered with fresh lumber before nightfall. When the aftershock comes at 3 a.m., nothing moves but the pots.');
},
},
{
label: 'Probably fine', run: (ctx) => {
ctx.s.camp.integrity = Math.max(10, ctx.s.camp.integrity - 10);
ctx.say('Probably fine. Except the storage lean-to, which chooses 4 a.m. to become archaeology.');
ctx.lose('food', Math.min(2, ctx.s.res.food));
},
},
],
},
/* ------------------------------------------------ ASHFALL BEAUTY */
{
id: 'ashfall_evening', title: 'Terrible and Beautiful', tag: 'story', weight: 7,
text: () => `The evening sky does something obscene with orange and ash, and for twenty minutes the ruined world looks like a painting of itself. Nobody speaks. Somewhere, a camera shutter memory clicks.`,
choices: [
{
label: 'Watch it together',
run: (ctx) => {
ctx.morale('all', 6);
const t = ctx.rng.pick(ctx.s.survivors.filter(A));
ctx.memory(t, 'The sky burned orange through the ash. We watched together.', 'good');
ctx.say('You sit in a row on the barricade like tourists at the end of the world. ' + t.name + ' says quietly: "Don\'t tell anyone we liked it."');
ctx.flag('hope', 1);
},
},
{ label: 'Get back to work', run: (ctx) => { ctx.say('Sunsets are free. Nothing else is. You keep hauling.'); } },
],
},
/* ------------------------------------------------ NIGHT THIEF OUTSIDE */
{
id: 'outside_thief', title: 'Small Hands in the Dark', tag: 'stranger', weight: 9, minDay: 6,
text: () => `The watchman's lamp pins a figure halfway over the storage wall — young, alone, frozen mid-crime with a bag of your rations under one arm.`,
choices: [
{
label: 'Catch and question them', run: (ctx) => {
if (ctx.rng.chance(0.6)) {
ctx.flag('karma', 1);
ctx.say('A kid, fourteen maybe, stealing for a crew that camps by the rail yard. She gives up their numbers, their watch schedule, everything — in exchange for walking away whole.');
ctx.discoverRandom();
if (ctx.rng.chance(0.4)) {
const j = ctx.joinGroup({ name: ctx.rng.pick(['Esme', 'Ada', 'Xiu']) });
if (j) { j.age = 15; j.occ = 'student'; ctx.say(`Two days later she's back at the fence. Not to steal — the rail yard crew went bad. ${j.name} asks, very quietly, to stay.`); }
}
} else {
ctx.say('They twist off the wall and vanish into the dark with the rations. Fast. Practiced. The camp checks the locks twice after.');
ctx.lose('food', Math.min(3, ctx.s.res.food));
}
},
},
{
label: 'Fire a warning shot', tip: 'Costs 1 ammo.',
req: { res: { ammo: 1 } },
run: (ctx) => { ctx.lose('ammo', 1); ctx.say('The shot goes wide on purpose. The thief goes over the wall backwards, leaving the rations behind. Message received on both sides.'); },
},
{ label: 'Let them run', run: (ctx) => { ctx.lose('food', Math.min(2, ctx.s.res.food)); ctx.say('You watch the small shadow sprint into the big darkness and decide the story explains itself.'); } },
],
},
/* ------------------------------------------------ WELL */
{
id: 'well_dig', title: 'Down to Water', tag: 'project', weight: 7,
text: () => `The engineer has been staring at the low corner of the camp for two days. "Water table's close here," they say, tapping the ground like a doctor. "I say we dig."`,
choices: [
{
label: 'Dig', tip: '-4 materials. Permanent water.',
req: { res: { materials: 4 } },
run: (ctx) => {
ctx.lose('materials', 4);
if (ctx.rng.chance(0.7)) {
ctx.gain('water', 10);
ctx.flag('well', 1);
ctx.say('Day and a half of digging and cursing — then the shovel comes up dark and dripping. The well gives sweet water and the camp gives three exhausted cheers.');
ctx.morale('all', 8); ctx.flag('hope', 1);
} else {
ctx.say('Six feet of clay, then gravel, then nothing but heat and flies. You fill it back in before anyone has to look at it again.');
ctx.morale('all', -2);
}
},
},
{ label: 'Trust the collectors', run: (ctx) => { ctx.say('Rain comes free, eventually. That word — eventually — is doing heavy lifting these days.'); } },
],
},
];
function weakest(s: import('../../engine/types').GameState) {
const alive = s.survivors.filter(A);
return alive.reduce((w, x) => (x.hp < w.hp ? x : w), alive[0]);
}
function bestAt(s: import('../../engine/types').GameState, k: import('../../engine/types').SkillId) {
const alive = s.survivors.filter(A);
return alive.reduce((best, x) => (skill(x, k) > skill(best, k) ? x : best), alive[0]);
}
+180
View File
@@ -0,0 +1,180 @@
import type { LocationTemplate } from '../engine/types';
/* Location templates. Instances are generated per-run by the engine with
randomized loot pools, risk drift and one rolled special discovery. */
export const LOCATION_TEMPLATES: LocationTemplate[] = [
{
id: 'house', name: 'Abandoned House', short: 'A family home, doors already open.',
risk: 12, tier: 1, lootUnits: 5, richness: 0.9, enemyChance: 0.08, enemyKind: 'looter',
loot: [
{ res: 'food', min: 1, max: 5, weight: 3 },
{ res: 'water', min: 1, max: 5, weight: 3 },
{ res: 'materials', min: 1, max: 4, weight: 3 },
{ res: 'medicine', min: 1, max: 2, weight: 1 },
{ res: 'tools', min: 1, max: 1, weight: 1 },
{ res: 'fuel', min: 1, max: 2, weight: 1 },
],
eventTags: ['domestic', 'ghost'],
specials: [],
props: 'house',
},
{
id: 'grocery', name: 'Grocery Store', short: 'Shelves like a graveyard of cans.',
risk: 25, tier: 1, lootUnits: 9, richness: 1.15, enemyChance: 0.18, enemyKind: 'looter',
loot: [
{ res: 'food', min: 3, max: 10, weight: 5 },
{ res: 'water', min: 2, max: 8, weight: 4 },
{ res: 'materials', min: 1, max: 3, weight: 1 },
],
eventTags: ['looter', 'crowd'],
specials: [],
props: 'grocery',
},
{
id: 'hospital', name: 'Hospital', short: 'Three floors of dark corridors.',
risk: 48, tier: 2, lootUnits: 10, richness: 1.3, enemyChance: 0.3, enemyKind: 'feral',
loot: [
{ res: 'medicine', min: 2, max: 7, weight: 5 },
{ res: 'materials', min: 1, max: 4, weight: 2 },
{ res: 'tools', min: 1, max: 2, weight: 1 },
{ res: 'water', min: 1, max: 3, weight: 1 },
],
eventTags: ['medical', 'ghost', 'feral'],
specials: [{ id: 'radio_part', name: 'Radio Part', text: 'A sealed field radio, half disassembled. Someone was trying to call for help from the roof.' }],
props: 'hospital',
},
{
id: 'police', name: 'Police Station', short: 'The armory door is still barricaded.',
risk: 42, tier: 2, lootUnits: 8, richness: 1.25, enemyChance: 0.28, enemyKind: 'militant',
loot: [
{ res: 'ammo', min: 2, max: 8, weight: 4 },
{ res: 'tools', min: 1, max: 2, weight: 2 },
{ res: 'medicine', min: 1, max: 3, weight: 1 },
{ res: 'materials', min: 1, max: 4, weight: 2 },
],
eventTags: ['weapons', 'militant', 'ghost'],
specials: [{ id: 'radio_part', name: 'Radio Part', text: 'An evidence-room radio rig, untouched. The charging light still blinks.' }],
props: 'police',
},
{
id: 'gasstation', name: 'Gas Station', short: 'Two pumps and a store full of nothing.',
risk: 30, tier: 1, lootUnits: 7, richness: 1.0, enemyChance: 0.16, enemyKind: 'looter',
loot: [
{ res: 'fuel', min: 3, max: 9, weight: 5 },
{ res: 'food', min: 1, max: 4, weight: 2 },
{ res: 'materials', min: 1, max: 3, weight: 2 },
],
eventTags: ['looter', 'traveler'],
specials: [{ id: 'vehicle', name: 'Old Pickup', text: 'A farm truck on flat tires. The engine might turn over with work — and fuel.' }],
props: 'gas',
},
{
id: 'warehouse', name: 'Warehouse', short: 'A cathedral of crates and rats.',
risk: 35, tier: 2, lootUnits: 12, richness: 1.35, enemyChance: 0.22, enemyKind: 'looter',
loot: [
{ res: 'materials', min: 3, max: 10, weight: 5 },
{ res: 'tools', min: 1, max: 2, weight: 2 },
{ res: 'food', min: 1, max: 6, weight: 2 },
{ res: 'fuel', min: 1, max: 3, weight: 1 },
],
eventTags: ['looter', 'dark'],
specials: [],
props: 'warehouse',
},
{
id: 'school', name: 'School', short: 'The gym shelter burned weeks ago.',
risk: 28, tier: 2, lootUnits: 8, richness: 1.05, enemyChance: 0.15, enemyKind: 'looter',
loot: [
{ res: 'food', min: 2, max: 6, weight: 3 },
{ res: 'materials', min: 2, max: 5, weight: 3 },
{ res: 'water', min: 1, max: 4, weight: 2 },
{ res: 'medicine', min: 1, max: 2, weight: 1 },
],
eventTags: ['domestic', 'ghost', 'children'],
specials: [{ id: 'city_map', name: 'District Map', text: 'A planning-office map with hand-marked supply caches. Several are circled.' }],
props: 'school',
},
{
id: 'farm', name: 'Farm', short: 'Dead fields, a stubborn barn.',
risk: 22, tier: 2, lootUnits: 9, richness: 1.1, enemyChance: 0.14, enemyKind: 'dog',
loot: [
{ res: 'food', min: 3, max: 8, weight: 4 },
{ res: 'materials', min: 2, max: 6, weight: 3 },
{ res: 'tools', min: 1, max: 2, weight: 1 },
{ res: 'fuel', min: 1, max: 3, weight: 1 },
],
eventTags: ['rural', 'animal', 'domestic'],
specials: [{ id: 'seed_vault', name: 'Seed Locker', text: 'A steel locker of sealed seed packets — tomatoes, beans, wheat. A future, in paper envelopes.' }],
props: 'farm',
},
{
id: 'checkpoint', name: 'Military Checkpoint', short: 'Sandbags, a dead Humvee, worse ideas.',
risk: 58, tier: 3, lootUnits: 10, richness: 1.5, enemyChance: 0.38, enemyKind: 'militant',
loot: [
{ res: 'ammo', min: 3, max: 10, weight: 4 },
{ res: 'fuel', min: 2, max: 6, weight: 2 },
{ res: 'medicine', min: 1, max: 4, weight: 2 },
{ res: 'tools', min: 1, max: 2, weight: 1 },
{ res: 'materials', min: 2, max: 5, weight: 2 },
],
eventTags: ['militant', 'weapons', 'military'],
specials: [{ id: 'radio_part', name: 'Radio Part', text: 'A command-tent transceiver. The frequency dial is set to a channel that still answers.' }],
props: 'checkpoint',
},
{
id: 'forest', name: 'Forest', short: 'Gray pines that survived everything.',
risk: 18, tier: 1, lootUnits: 99, richness: 0.8, enemyChance: 0.12, enemyKind: 'dog',
loot: [
{ res: 'materials', min: 1, max: 4, weight: 3 },
{ res: 'food', min: 1, max: 4, weight: 3 },
{ res: 'medicine', min: 1, max: 2, weight: 1 },
],
eventTags: ['animal', 'rural', 'hermit'],
specials: [],
props: 'forest',
},
{
id: 'highway', name: 'Highway', short: 'A parking lot of the departed.',
risk: 26, tier: 2, lootUnits: 10, richness: 1.0, enemyChance: 0.2, enemyKind: 'looter',
loot: [
{ res: 'fuel', min: 2, max: 7, weight: 4 },
{ res: 'materials', min: 2, max: 6, weight: 4 },
{ res: 'food', min: 1, max: 3, weight: 1 },
{ res: 'ammo', min: 1, max: 3, weight: 1 },
],
eventTags: ['traveler', 'convoy'],
specials: [{ id: 'vehicle', name: 'Delivery Van', text: 'A bakery van, engine intact, back full of long-dead bread. It could run.' }],
props: 'highway',
},
{
id: 'town', name: 'Small Town', short: 'Main Street, population zero.',
risk: 45, tier: 3, lootUnits: 14, richness: 1.45, enemyChance: 0.3, enemyKind: 'feral',
loot: [
{ res: 'food', min: 2, max: 8, weight: 3 },
{ res: 'materials', min: 2, max: 7, weight: 3 },
{ res: 'fuel', min: 1, max: 5, weight: 2 },
{ res: 'medicine', min: 1, max: 4, weight: 2 },
{ res: 'ammo', min: 1, max: 4, weight: 1 },
],
eventTags: ['feral', 'looter', 'ghost', 'survivor'],
specials: [],
props: 'town',
},
{
id: 'clinic', name: 'Roadside Clinic', short: 'A doctor\'s office behind a gas station.',
risk: 20, tier: 1, lootUnits: 6, richness: 1.0, enemyChance: 0.1, enemyKind: 'none',
loot: [
{ res: 'medicine', min: 2, max: 5, weight: 4 },
{ res: 'materials', min: 1, max: 3, weight: 1 },
{ res: 'water', min: 1, max: 2, weight: 1 },
],
eventTags: ['medical', 'domestic'],
specials: [],
props: 'clinic',
},
];
export function templateById(id: string): LocationTemplate {
return LOCATION_TEMPLATES.find(t => t.id === id)!;
}
+213
View File
@@ -0,0 +1,213 @@
import type {
GoalId, OccupationDef, PersonalityId, Survivor, TraitId, LookParams,
} from '../engine/types';
import type { Rng } from '../engine/types';
/* ---------------- Names ---------------- */
export const FIRST_NAMES = [
'Minh', 'Anna', 'Jack', 'Sarah', 'Elias', 'Mara', 'Dario', 'Ines', 'Kofi',
'Lena', 'Piotr', 'Yuki', 'Omar', 'Bea', 'Tomas', 'Ruth', 'Andrej', 'Camille',
'Dev', 'Halina', 'Jonas', 'Keiko', 'Luca', 'Noor', 'Petra', 'Quinn', 'Rafa',
'Sana', 'Teo', 'Ulla', 'Viktor', 'Wren', 'Xiu', 'Yara', 'Zane', 'Ada',
'Bruno', 'Cleo', 'Dmitri', 'Esme', 'Farid', 'Greta', 'Hugo', 'Iris',
];
export const NICKNAMES = [
'the Quiet', 'Two-Thumb', 'Sparrow', 'the Fox', 'Ash', 'Doc', 'Whisper',
'Bolt', 'Moss', 'Half-Luck', 'Saint', 'Rook', 'Ember', 'Nine-Fingers',
];
/* ---------------- Occupations ---------------- */
export const OCCUPATIONS: OccupationDef[] = [
{ id: 'leader', label: 'Leader', skills: { charisma: 3, combat: 1 }, favoredTraits: ['brave', 'loyal'], goalBias: ['keepAlive', 'rebuild'] },
{ id: 'doctor', label: 'Doctor', skills: { medicine: 4, charisma: 1, combat: -1 }, favoredTraits: ['compassionate', 'calm'], goalBias: ['keepAlive'] },
{ id: 'hunter', label: 'Hunter', skills: { hunt: 4, combat: 1 }, favoredTraits: ['tough', 'quiet'], goalBias: ['surviveAtAnyCost'] },
{ id: 'engineer', label: 'Engineer', skills: { engineering: 4 }, favoredTraits: ['hardworker', 'sharpEye'], goalBias: ['rebuild'] },
{ id: 'scavenger', label: 'Scavenger', skills: { scavenge: 4 }, favoredTraits: ['sharpEye', 'greedy'], goalBias: ['reachCoast', 'surviveAtAnyCost'] },
{ id: 'soldier', label: 'Soldier', skills: { combat: 4 }, favoredTraits: ['brave', 'hotheaded'], goalBias: ['protectPerson', 'atone'] },
{ id: 'nurse', label: 'Nurse', skills: { medicine: 3, charisma: 1 }, favoredTraits: ['compassionate'], goalBias: ['keepAlive'] },
{ id: 'farmer', label: 'Farmer', skills: { hunt: 2, engineering: 1 }, favoredTraits: ['greenThumb', 'hardworker'], goalBias: ['rebuild'] },
{ id: 'mechanic', label: 'Mechanic', skills: { engineering: 3, scavenge: 1 }, favoredTraits: ['hardworker', 'funny'], goalBias: ['reachCoast'] },
{ id: 'teacher', label: 'Teacher', skills: { charisma: 3, scavenge: 1 }, favoredTraits: ['optimist', 'calm'], goalBias: ['rebuild'] },
{ id: 'cook', label: 'Cook', skills: { charisma: 2, scavenge: 1 }, favoredTraits: ['funny', 'ironStomach'], goalBias: ['keepAlive'] },
{ id: 'police', label: 'Police Officer', skills: { combat: 3, charisma: 1 }, favoredTraits: ['loyal', 'brave'], goalBias: ['protectPerson'] },
{ id: 'student', label: 'Student', skills: {}, favoredTraits: ['optimist', 'cowardly'], goalBias: ['findFamily'] },
{ id: 'postal', label: 'Postal Worker', skills: { scavenge: 2, charisma: 1 }, favoredTraits: ['loyal', 'quiet'], goalBias: ['findFamily', 'reachCoast'] },
];
/* ---------------- Traits ---------------- */
export const TRAITS: Record<TraitId, { label: string; desc: string }> = {
compassionate: { label: 'Compassionate', desc: 'Comforts others; morale of the group decays slower.' },
brave: { label: 'Brave', desc: 'Fights better; steadies others in encounters.' },
cowardly: { label: 'Cowardly', desc: 'May refuse dangerous tasks; hides more successfully.' },
greedy: { label: 'Greedy', desc: 'Skews shared loot; may steal at low morale.' },
optimist: { label: 'Optimist', desc: 'High morale baseline.' },
pessimist: { label: 'Pessimist', desc: 'Low morale baseline; rarely surprised.' },
hardworker: { label: 'Hardworker', desc: 'Extra yield from labor actions.' },
lazy: { label: 'Lazy', desc: 'Rests more, works less. Still lovable. Sometimes.' },
hotheaded: { label: 'Hot-headed', desc: 'Strong in a fight, prone to arguments.' },
calm: { label: 'Calm', desc: 'Resists panic; good in negotiations.' },
loyal: { label: 'Loyal', desc: 'Never deserts; defends friends.' },
ambitious: { label: 'Ambitious', desc: 'Wants influence; resents ignored advice.' },
quiet: { label: 'Quiet', desc: 'Harder to detect while hiding.' },
funny: { label: 'Funny', desc: 'Raises group morale around the fire.' },
greenThumb: { label: 'Green Thumb', desc: 'Garden produces extra food.' },
ironStomach: { label: 'Iron Stomach', desc: 'Hunger bites slower.' },
nightOwl: { label: 'Night Owl', desc: 'Better watchman; spots night threats.' },
fragile: { label: 'Fragile', desc: 'Injuries hit harder and heal slower.' },
tough: { label: 'Tough', desc: 'Shrugs off wounds; injuries heal faster.' },
sharpEye: { label: 'Sharp Eye', desc: 'Finds more while scavenging.' },
};
/* ---------------- Personalities ---------------- */
export const PERSONALITIES: Record<PersonalityId, { label: string; desc: string }> = {
protector: { label: 'Protector', desc: 'Puts others between danger and the weak.' },
caretaker: { label: 'Caretaker', desc: 'Drawn to the wounded and the lost.' },
loner: { label: 'Loner', desc: 'Prefers their own company; bonds slowly.' },
joker: { label: 'Joker', desc: 'Laughs so the others don\'t cry.' },
schemer: { label: 'Schemer', desc: 'Always playing three moves ahead.' },
believer: { label: 'Believer', desc: 'Insists this means something.' },
};
/* ---------------- Goals ---------------- */
export const GOALS: Record<GoalId, string[]> = {
keepAlive: ['Keep everyone alive.', 'Get this group through the winter.', 'Bury no one else.'],
findFamily: ['Find out if their family is alive.', 'Reach the city before the roads close.', 'Follow every rumor south.'],
reachCoast: ['Reach the coast and a boat.', 'See the ocean before it ends.', 'Get out — anywhere but here.'],
atone: ['Atone for what they did during the Collapse.', 'Make up for the people they left behind.', 'Earn forgiveness, even posthumously.'],
rebuild: ['Plant something that outlives them.', 'Rebuild a piece of the old world.', 'Leave a working light behind.'],
surviveAtAnyCost: ['Survive. Whatever it takes.', 'Outlast everyone and everything.', 'Never be helpless again.'],
protectPerson: ['Protect %N, no matter the cost.', 'Keep %N out of the line of fire.', 'Be there when %N needs them.'],
};
/* ---------------- Weapons ---------------- */
export const WEAPONS: Record<string, { name: string; power: number; usesAmmo: boolean; desc: string }> = {
crowbar: { name: 'Crowbar', power: 4, usesAmmo: false, desc: 'Reliable. Opens doors and skulls.' },
machete: { name: 'Machete', power: 7, usesAmmo: false, desc: 'Quiet, brutal, close-range.' },
pistol: { name: 'Pistol', power: 11, usesAmmo: true, desc: 'Loud. Each shot spends ammunition.' },
rifle: { name: 'Hunting Rifle', power: 15, usesAmmo: true, desc: 'Distance and stopping power.' },
shotgun: { name: 'Shotgun', power: 14, usesAmmo: true, desc: 'Ends arguments quickly.' },
bat: { name: 'Nailed Bat', power: 5, usesAmmo: false, desc: 'Ugly, effective.' },
};
/* ---------------- Portrait look palettes ---------------- */
export const SKIN_TONES = ['#e8b48c', '#c98d63', '#9c6644', '#7a4a2f', '#f0c8a0', '#5d3a24'];
export const HAIR_COLORS = ['#201a17', '#3d2b1f', '#6b4423', '#8c7853', '#b8b8bd', '#7a2e1f', '#1a1a22'];
export const COAT_COLORS = ['#3a3f4a', '#4a3b32', '#37453a', '#42303c', '#2f3d46', '#463f33'];
export const ACCENTS = ['#d97742', '#5da9a1', '#c95d63', '#8ea65d', '#a97fc9', '#c9b458', '#6d94c9'];
const HAIR_STYLES = 6;
const FACE_HAIR_STYLES = 4;
/* ---------------- Generation ---------------- */
export interface GenOptions {
occId?: string;
name?: string;
joinDay?: number;
}
export function generateSurvivor(rng: Rng, id: string, opts: GenOptions = {}): Survivor {
const occ = opts.occId
? OCCUPATIONS.find(o => o.id === opts.occId)!
: rng.pick(OCCUPATIONS);
const skills: Record<string, number> = {};
const mid = rng.i(2, 4);
for (const s of ['combat', 'scavenge', 'hunt', 'engineering', 'medicine', 'charisma'] as const) {
let v = mid + (occ.skills[s] ?? 0) + rng.i(-1, 1);
v = Math.max(1, Math.min(9, v));
skills[s] = v;
}
// Traits: one favored (if any remain), plus 1-2 random distinct
const pool = Object.keys(TRAITS) as TraitId[];
const chosen: TraitId[] = [];
const favored = occ.favoredTraits.filter(t => !chosen.includes(t));
if (favored.length && rng.chance(0.85)) chosen.push(rng.pick(favored));
while (chosen.length < rng.i(2, 3)) {
const t = rng.pick(pool);
if (!chosen.includes(t)) chosen.push(t);
}
const personality = rng.pick(Object.keys(PERSONALITIES) as PersonalityId[]);
const goalPool = occ.goalBias.length ? [...occ.goalBias, ...occ.goalBias, ...(Object.keys(GOALS) as GoalId[])] : (Object.keys(GOALS) as GoalId[]);
let goalId = rng.pick(goalPool);
if (goalId === 'protectPerson' && rng.chance(0.25)) goalId = 'keepAlive';
const look: LookParams = {
skin: rng.i(0, SKIN_TONES.length - 1),
hair: rng.i(0, HAIR_STYLES - 1),
hairColor: rng.i(0, HAIR_COLORS.length - 1),
coat: rng.i(0, COAT_COLORS.length - 1),
accent: rng.pick(ACCENTS),
build: rng.i(0, 2),
faceHair: rng.chance(0.28) ? rng.i(0, FACE_HAIR_STYLES - 1) : -1,
brow: rng.i(0, 2),
};
const age = occ.id === 'student' ? rng.i(16, 22) : rng.i(24, 58);
const name = opts.name ?? rng.pick(FIRST_NAMES);
const s: Survivor = {
id,
name,
age,
occ: occ.id,
occLabel: occ.label,
skills: skills as Survivor['skills'],
traitIds: chosen,
personality,
personalityLabel: PERSONALITIES[personality].label,
goalId,
goalText: '',
hp: 100,
hunger: 80,
morale: rng.i(52, 72),
injuries: [],
sick: false,
weapon: null,
memories: [],
portraitSeed: rng.i(0, 999999),
look,
alive: true,
joinedDay: opts.joinDay ?? 1,
};
s.goalText = goalTextFor(s, null);
return s;
}
/** Fill %N placeholders once we know companions. */
export function goalTextFor(s: Survivor, other: Survivor | null): string {
let tmpl = rnglessPick(GOALS[s.goalId], s.portraitSeed);
if (tmpl.includes('%N')) {
tmpl = other ? tmpl.replace('%N', other.name) : tmpl.replace('%N', 'the others');
}
return tmpl;
}
function rnglessPick<T>(arr: T[], seed: number): T {
return arr[seed % arr.length];
}
export function hasTrait(s: Survivor, t: TraitId): boolean {
return s.traitIds.includes(t);
}
export function skill(s: Survivor, k: keyof Survivor['skills']): number {
let v = s.skills[k];
// injuries degrade performance
for (const inj of s.injuries) {
if (inj.severity >= 3) v -= 2;
else if (inj.severity === 2) v -= 1;
}
if (s.hunger < 15) v -= 1;
return Math.max(0, v);
}
+72
View File
@@ -0,0 +1,72 @@
import type { WeatherId } from '../engine/types';
/* ---------------- Weather ---------------- */
export interface WeatherDef {
id: WeatherId;
label: string;
icon: string;
desc: string;
foodNeed: number; // extra food per survivor
waterNeed: number; // extra water per survivor
morale: number; // daily morale drift for everyone
scavenge: number; // multiplier on loot rolls
combat: number; // multiplier on party power
travel: number; // multiplier on travel risk
ambush: number; // extra ambush chance
scene: string; // scene renderer palette hint
}
export const WEATHER: Record<WeatherId, WeatherDef> = {
clear: { id: 'clear', label: 'Clear', icon: '☀', desc: 'Good visibility. The dust hangs anyway.', foodNeed: 0, waterNeed: 0, morale: 0, scavenge: 1.0, combat: 1.0, travel: 1.0, ambush: 0, scene: 'clear' },
rain: { id: 'rain', label: 'Rain', icon: '🌧', desc: 'Cold water from a poisoned sky. Collectors love it.', foodNeed: 0.5, waterNeed: 0, morale: -1, scavenge: 0.9, combat: 0.95, travel: 1.1, ambush: 0.02, scene: 'rain' },
storm: { id: 'storm', label: 'Storm', icon: '⛈', desc: 'The Grayfall comes down thick. Nobody should be outside.', foodNeed: 0.5, waterNeed: 0, morale: -3, scavenge: 0.55, combat: 0.85, travel: 1.6, ambush: 0.04, scene: 'storm' },
fog: { id: 'fog', label: 'Fog', icon: '🌫', desc: 'You hear things long before you see them.', foodNeed: 0, waterNeed: 0, morale: -1, scavenge: 0.85, combat: 0.9, travel: 1.25, ambush: 0.08, scene: 'fog' },
cold: { id: 'cold', label: 'Cold', icon: '❄', desc: 'Frost on the inside of the windows.', foodNeed: 1, waterNeed: 0, morale: -2, scavenge: 0.9, combat: 0.92, travel: 1.2, ambush: 0, scene: 'cold' },
heat: { id: 'heat', label: 'Heat', icon: '🔥', desc: 'Everything smells ripe. Water is life.', foodNeed: 0, waterNeed: 1, morale: -1, scavenge: 1.05, combat: 0.95, travel: 1.15, ambush: 0.01, scene: 'heat' },
};
/* ---------------- Campaign phases ---------------- */
export type PhaseId = 'early' | 'expansion' | 'escalation' | 'endgame' | 'finale';
export function phaseOf(day: number): PhaseId {
if (day <= 20) return 'early';
if (day <= 50) return 'expansion';
if (day <= 80) return 'escalation';
if (day <= 99) return 'endgame';
return 'finale';
}
export const PHASE_LABEL: Record<PhaseId, string> = {
early: 'Early Survival',
expansion: 'Expansion',
escalation: 'Escalation',
endgame: 'Endgame',
finale: 'Day 100',
};
export function enemyScale(day: number): number {
return 1 + Math.min(1.1, day / 130);
}
/* ---------------- Lore fragments ---------------- */
export const RADIO_FRAGMENTS = [
'…repeat, this is UNCOR Southern Command… any station…',
'…the ashfall is not volcanic. Repeat, NOT volcanic…',
'…containment failed at Site Grey. May God forgive us…',
'…crops failing worldwide within six weeks of Grayfall…',
'…do not eat anything the dust has touched. Do not BURN it…',
'…southern islands remain clean. Clean. CLEAN…',
'…convoy Gamma will run the coastal road until Day 100. After that, no promise…',
];
export const DOCUMENT_LINES = [
'A water-stained notebook: “Day 12. The store was already picked clean. People were polite about it. That won\'t last.”',
'A child\'s drawing pinned to a wall: two stick figures under a gray sun. One has been scratched out.',
'A printed memo, OFFICIAL USE ONLY: “Seed viability across the northern hemisphere: catastrophic.”',
'A diary: “They say the dust came down for nine days. We hid in the metro for eight of them.”',
'A hospital chart taped to a door: patient name scratched out. Cause of death: “unclear — see addendum.” There is no addendum.',
'A hand-painted sign at the road: TURN BACK. WE MEAN IT. Underneath, smaller: “sorry”.',
];
+71
View File
@@ -0,0 +1,71 @@
import type { GameState } from './types';
import { ENDINGS } from '../content/events/story';
import { createRng } from './rng';
import { A, aliveOf } from './sim-core';
/** Decide and set the ending once the Day-100 finale path resolves.
* Also handles the all-dead case at any time. */
export function finalizeEnding(s: GameState): string {
if (s.endingId) return s.endingId;
const survivors = aliveOf(s);
if (!survivors.length) {
return applyEnding(s, 'silence');
}
// If we reached past day 100 without a finale (shouldn't happen), default.
if (s.day > 100 && !s.flags.finale_path) {
return applyEnding(s, 'wanderers');
}
if (!s.flags.finale_path) {
// still mid-game; no ending yet
return '';
}
switch (s.flags.finale_path) {
case 1: { // convoy muster
const wellEquipped = (s.res.medicine >= 3 || (s.camp.buildings.medical ?? 0) >= 2)
&& (s.camp.buildings.generator ?? 0) >= 1;
return applyEnding(s, wellEquipped ? 'rescue' : 'military_evacuation');
}
case 2: { // coastal run
const rng = createRng(s.seed ^ 0xD100, s.day);
const supplies = s.res.food >= 10 && s.res.water >= 10;
const luck = rng.f() < (supplies ? 0.85 : 0.55);
return applyEnding(s, luck ? 'coastal_escape' : 'wanderers');
}
case 3: { // hold the ground
const won = !!s.flags.hold_won;
if (!won) return applyEnding(s, survivors.length >= 3 ? 'wanderers' : 'silence');
return applyEnding(s, s.strFlags.hold_fallen ? 'sacrifice' : 'new_settlement');
}
case 4: { // beacon
return applyEnding(s, 'civilization');
}
case 5: { // failed beacon
return applyEnding(s, 'wanderers');
}
}
return applyEnding(s, 'wanderers');
}
function applyEnding(s: GameState, id: string): string {
s.endingId = id;
s.phase = 'gameover';
s.log.push({ day: Math.min(100, s.day), text: `ENDING — ${ENDINGS[id]?.title ?? id}`, kind: 'story' });
return id;
}
/** Epilogue data for the ending screen. */
export function epilogue(s: GameState): { name: string; fate: string; tone: 'good' | 'bad' | 'gone' | 'alive' }[] {
return s.survivors.map(x => {
if (x.gone) return { name: x.name, fate: `Left on Day ${x.goneDay}${x.goneReason?.toLowerCase()}`, tone: 'gone' as const };
if (!x.alive) return { name: x.name, fate: `Died Day ${x.deathDay}${x.deathCause}`, tone: 'bad' as const };
const goal = x.goalText.replace(/\.$/, '');
return { name: x.name, fate: `Survived. ${x.occLabel}. Goal: ${goal}`, tone: 'alive' as const };
});
}
export function anyoneAlive(s: GameState): boolean {
return s.survivors.some(A);
}
+244
View File
@@ -0,0 +1,244 @@
import type {
EventCast, EventCtx, GameState, Injury, LootEntry, ResourceId,
Rng, Survivor, EnemyDef, LogEntry,
} from './types';
import { generateSurvivor } from '../content/survivors';
import {
A, aliveOf, applyMorale, addRel, hasTrait,
} from './sim-core';
export interface CtxSession {
lines: LogEntry[];
}
/** Build an EventCtx bound to a state + rng. Narration accumulates in session.lines. */
export function makeCtx(s: GameState, rng: Rng, cast: EventCast, session: CtxSession): EventCtx {
const push = (text: string, tone: LogEntry['kind'] = 'info') => {
session.lines.push({ day: s.day, text, kind: tone });
s.log.push({ day: s.day, text, kind: tone });
};
const ctx: EventCtx = {
s,
rng,
cast,
say: (text, tone) => push(text, tone ?? 'info'),
gain(res, n) {
if (n <= 0) return;
s.res[res] += n;
},
lose(res, n) {
if (n <= 0) return;
s.res[res] = Math.max(0, s.res[res] - n);
},
hasRes: (res, n) => s.res[res] >= n,
morale(target, delta) {
if (target === 'all') {
for (const t of aliveOf(s)) applyMorale(s, t, delta);
} else {
applyMorale(s, target as Survivor, delta);
}
},
hp(target, delta) {
target.hp = Math.max(1, Math.min(100, target.hp + delta));
},
hurt(target, kind, label, sev, days) {
if (hasTrait(target, 'fragile')) days += 2;
if (hasTrait(target, 'tough')) days = Math.max(1, days - 1);
const inj: Injury = { kind, label, severity: sev, daysLeft: days };
target.injuries.push(inj);
target.hp = Math.max(5, target.hp - sev * 8);
push(`${target.name}: ${label}.`, 'bad');
},
healInjury(target) {
if (!target.injuries.length) return false;
target.injuries.sort((a, b) => b.severity - a.severity);
const worst = target.injuries.shift()!;
if (target.hp < 100) target.hp = Math.min(100, target.hp + 12);
push(`${worst.label} — healed for ${target.name}.`, 'good');
return true;
},
rel: (a, b, delta) => addRel(s, a, b, delta),
memory(target, text, tone = 'neutral') {
target.memories.push({ day: s.day, text, tone });
if (target.memories.length > 24) target.memories.shift();
},
flag(key, v = 1) {
s.flags[key] = (s.flags[key] ?? 0) + v;
if (s.flags[key] === 0 && v === 0) delete s.flags[key];
},
discoverRandom() {
const hidden = s.locations.filter(l => !l.discovered);
if (!hidden.length) return null;
const loc = rng.pick(hidden);
loc.discovered = true;
s.log.push({ day: s.day, text: `New location discovered: ${loc.name}`, kind: 'good' });
return loc;
},
queueEvent(eventId, inDays, payload) {
s.queuedEvents.push({
eventId,
dueDay: s.day + Math.max(0, inDays),
payloadName: payload?.name,
payloadId: payload?.id,
});
},
joinGroup(opts) {
const count = aliveOf(s).length;
if (count >= 9) return null;
const id = `s${s.nextSurvivorNum++}`;
const ns = generateSurvivor(rng, id, { name: opts?.name, joinDay: s.day });
ns.morale = Math.min(ns.morale, 55); // newcomers are wary
ns.hunger = Math.max(30, ns.hunger - 20);
s.survivors.push(ns);
for (const o of aliveOf(s)) {
if (o.id !== ns.id) addRel(s, o.id, ns.id, rng.i(-6, 12));
}
s.log.push({ day: s.day, text: `${ns.name} (${ns.occLabel}) joined the camp.`, kind: 'good' });
push(`${ns.name} joins the camp — ${ns.occLabel}, ${ns.age}.`, 'good');
return ns;
},
leaveGroup(survivor, reason) {
survivor.gone = true;
survivor.goneDay = s.day;
survivor.goneReason = reason;
s.log.push({ day: s.day, text: `${survivor.name} left the camp — ${reason.toLowerCase()}.`, kind: 'bad' });
for (const o of aliveOf(s)) {
const v = s.rel[relKeyOf(o.id, survivor.id)] ?? 0;
applyMorale(s, o, v > 40 ? -8 : -3);
}
if (survivor.coupleWith) {
const p = s.survivors.find(x => x.id === survivor.coupleWith);
if (p && A(p)) {
p.coupleWith = undefined;
applyMorale(s, p, -15);
p.memories.push({ day: s.day, text: `${survivor.name} left. Just left.`, tone: 'bad' });
}
survivor.coupleWith = undefined;
}
},
kill(survivor, cause) {
killAndGrief(s, survivor, cause, session);
},
encounter(spec) {
queueEncounter(
s,
{
kind: 'event', name: spec.enemyName, strength: spec.strength,
perception: spec.perception, speed: spec.speed, greed: spec.greed,
loot: spec.loot, desc: spec.enemyDesc,
},
spec.party, spec.intro, spec.context ?? 'event',
);
},
logWorld: (text, tone) => {
s.log.push({ day: s.day, text, kind: tone ?? 'info' });
},
};
return ctx;
}
import { relKey as relKeyOf } from './sim-core';
/* ---------------- death & grief ---------------- */
export function killAndGrief(s: GameState, victim: Survivor, cause: string, session?: CtxSession) {
if (!victim.alive || victim.gone) return;
victim.alive = false;
victim.deathDay = s.day;
victim.deathCause = cause;
victim.hp = 0;
s.stats.lost++;
const line = `${victim.name} is dead — ${cause}.`;
s.log.push({ day: s.day, text: line, kind: 'bad' });
session?.lines.push({ day: s.day, text: line, kind: 'bad' });
for (const o of aliveOf(s)) {
const v = s.rel[relKeyOf(o.id, victim.id)] ?? 0;
if (v >= 60) {
applyMorale(s, o, -18);
o.memories.push({ day: s.day, text: `${victim.name} died. ${victim.name} was family.`, tone: 'bad' });
s.flags[`grief_${o.id}`] = (s.flags[`grief_${o.id}`] ?? 0) + 1;
s.strFlags[`grief_about_${o.id}`] = victim.name;
} else if (v >= 20) {
applyMorale(s, o, -10);
o.memories.push({ day: s.day, text: `Lost ${victim.name}.`, tone: 'bad' });
} else {
applyMorale(s, o, -6);
}
if (hasTrait(o, 'compassionate')) applyMorale(s, o, -3);
}
if (victim.coupleWith) {
const p = s.survivors.find(x => x.id === victim.coupleWith);
if (p && A(p)) {
p.coupleWith = undefined;
applyMorale(s, p, -20);
p.memories.push({ day: s.day, text: `Buried ${victim.name}. The world got smaller.`, tone: 'bad' });
s.flags[`grief_${p.id}`] = (s.flags[`grief_${p.id}`] ?? 0) + 1;
s.strFlags[`grief_about_${p.id}`] = victim.name;
}
victim.coupleWith = undefined;
}
// funeral event queued for next morning (dynamic event)
s.queuedEvents.push({
eventId: `dyn:funeral:${encodeURIComponent(victim.name)}:${encodeURIComponent(cause)}:${victim.occLabel}`,
dueDay: Math.min(100, s.day + 1),
});
s.flags.hope = Math.max(-5, (s.flags.hope ?? 0) - 1);
}
/* ---------------- encounters ---------------- */
export function queueEncounter(
s: GameState, enemyDef: EnemyDef, partyIds: string[], intro: string,
context: import('./types').Encounter['context'], strengthScale = 1,
) {
const ids = partyIds.filter(id => {
const x = s.survivors.find(y => y.id === id);
return x !== undefined && A(x);
});
s.pending.push({
enemyName: enemyDef.name,
enemyDesc: enemyDef.desc,
strength: Math.round(enemyDef.strength * strengthScale),
perception: enemyDef.perception,
speed: enemyDef.speed,
greed: enemyDef.greed,
lootOnWin: enemyDef.loot,
party: ids,
intro,
context,
});
}
export function rollLoot(rng: Rng, table: LootEntry[], mult = 1): Partial<Record<ResourceId, number>> {
// Each entry is a probabilistic find; mult shifts both chance and plenty.
const out: Partial<Record<ResourceId, number>> = {};
for (const e of table) {
const p = Math.min(0.95, e.weight * 0.17 * Math.max(0.2, mult));
if (!rng.chance(p)) continue;
let amt = rng.i(e.min, e.max);
if (mult > 1.4 && rng.chance((mult - 1) * 0.5)) amt += rng.i(e.min, e.max);
out[e.res] = (out[e.res] ?? 0) + amt;
}
return out;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Rng } from './types';
/** Deterministic mulberry32 PRNG wrapped in the Rng helper API.
* The whole simulation draws from this, so a seed + cursor fully
* reproduces a run. `cursor()` returns exact draw count for serialization. */
export function createRng(seed: number, cursor = 0): Rng {
let a = (seed ^ 0x9e3779b9) >>> 0;
let ticks = 0;
function stepOnce(): number {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
for (let i = 0; i < cursor; i++) stepOnce();
ticks = cursor;
const rng: Rng = {
f: () => { ticks++; return stepOnce(); },
i: (min, max) => { ticks++; return Math.floor(stepOnce() * (max - min + 1)) + min; },
chance: (p) => rng.f() < p,
pick: (arr) => arr[rng.i(0, arr.length - 1)],
pickN: (arr, n) => rng.shuffle([...arr]).slice(0, n),
shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = rng.i(0, i);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
},
weighted(items, w) {
let total = 0;
for (const it of items) total += Math.max(0, w(it));
if (total <= 0) return rng.pick(items);
const r = rng.f() * total;
let acc = 0;
for (const it of items) { acc += Math.max(0, w(it)); if (r <= acc) return it; }
return items[items.length - 1];
},
cursor: () => ticks,
};
return rng;
}
export function randomSeed(): number {
return (Math.floor(Math.random() * 0xffffffff) >>> 0);
}
+79
View File
@@ -0,0 +1,79 @@
import type { GameState } from './types';
import { SAVE_VERSION } from './state';
const KEY_PREFIX = 'd100_';
export const AUTO_SLOT = 'auto';
export const MAX_SLOTS = 3;
export interface SaveMeta {
slot: string;
day: number;
label: string;
ts: number;
seed: number;
survivors: string;
}
interface SaveEnvelope {
v: number;
ts: number;
state: GameState;
}
function keyOf(slot: string): string {
return `${KEY_PREFIX}save_${slot}`;
}
export function saveGame(slot: string, s: GameState): boolean {
try {
const env: SaveEnvelope = { v: SAVE_VERSION, ts: Date.now(), state: s };
localStorage.setItem(keyOf(slot), JSON.stringify(env));
return true;
} catch (e) {
console.error('save failed', e);
return false;
}
}
export function loadGame(slot: string): GameState | null {
try {
const raw = localStorage.getItem(keyOf(slot));
if (!raw) return null;
const env = JSON.parse(raw) as SaveEnvelope;
if (!env || typeof env.v !== 'number' || env.v > SAVE_VERSION) return null;
const st = env.state;
// forward-fill any missing fields from newer versions
if (!st.pending) st.pending = [];
if (!st.strFlags) st.strFlags = {};
if (!st.flags) st.flags = {};
return st;
} catch (e) {
console.error('load failed', e);
return null;
}
}
export function deleteSlot(slot: string) {
localStorage.removeItem(keyOf(slot));
}
export function listSlots(): SaveMeta[] {
const out: SaveMeta[] = [];
for (const slot of [AUTO_SLOT, ...Array.from({ length: MAX_SLOTS }, (_, i) => String(i + 1))]) {
try {
const raw = localStorage.getItem(keyOf(slot));
if (!raw) continue;
const env = JSON.parse(raw) as SaveEnvelope;
const st = env.state;
out.push({
slot,
day: st.day,
label: `Day ${st.day}`,
ts: env.ts ?? 0,
seed: st.seed,
survivors: st.survivors.filter(x => x.alive && !x.gone).map(x => x.name).join(', ') || '—',
});
} catch { /* corrupt slot ignored */ }
}
return out.sort((a, b) => b.ts - a.ts);
}
+78
View File
@@ -0,0 +1,78 @@
import type { GameState, ResourceId, SkillId, Survivor } from './types';
export const A = (x: Survivor) => x.alive && !x.gone;
export const aliveOf = (s: GameState) => s.survivors.filter(A);
export function relKey(a: string, b: string): string {
return [a, b].sort().join('|');
}
export function hasTrait(x: Survivor, t: string): boolean {
return (x.traitIds as string[]).includes(t);
}
export function effSkill(x: Survivor, k: SkillId): number {
let v = x.skills[k] ?? 0;
for (const inj of x.injuries) {
if (inj.severity >= 3) v -= 2;
else if (inj.severity === 2) v -= 1;
}
if (x.hunger < 15) v -= 1;
return Math.max(0, v);
}
export function applyMorale(_s: GameState, t: Survivor, delta: number) {
let d = delta;
if (d < 0 && hasTrait(t, 'calm')) d = Math.round(d * 0.7);
if (d > 0 && hasTrait(t, 'pessimist')) d = Math.round(d * 0.7);
t.morale = Math.max(0, Math.min(100, t.morale + d));
}
export function addRel(s: GameState, a: string, b: string, delta: number) {
if (a === b) return;
const key = relKey(a, b);
const cur = s.rel[key] ?? 0;
s.rel[key] = Math.max(-100, Math.min(100, cur + delta));
}
/* ---------------- resource caps ---------------- */
export function capsOf(s: GameState): Record<ResourceId, number> {
const lvl = s.camp.buildings.storage ?? 0;
return {
food: 80 + lvl * 40,
water: 80 + lvl * 40,
medicine: 50 + lvl * 25,
fuel: 50 + lvl * 25,
materials: 60 + lvl * 25,
ammo: 50 + lvl * 25,
tools: 20 + lvl * 10,
};
}
export function clampRes(s: GameState) {
const caps = capsOf(s);
for (const k of Object.keys(s.res) as ResourceId[]) {
s.res[k] = Math.min(caps[k], s.res[k]);
}
}
export function groupMorale(s: GameState): number {
const a = aliveOf(s);
if (!a.length) return 0;
return Math.round(a.reduce((t, x) => t + x.morale, 0) / a.length);
}
export function moraleFloor(s: GameState): number {
// shelter raises the floor; hope shifts it
return Math.min(30, (s.camp.buildings.shelter ?? 0) * 6) + (s.flags.hope ?? 0) * 2;
}
export function defenseRating(s: GameState): number {
const tower = s.camp.buildings.watchtower ?? 0;
let d = tower * 14;
for (const x of aliveOf(s)) d += effSkill(x, 'combat') * 3;
if (s.res.ammo > 4) d += 8;
if (s.camp.integrity < 50) d -= 10;
return d;
}
+1130
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import type { GameState, LocationInst, ResourceId } from './types';
import { createRng, randomSeed } from './rng';
import { generateSurvivor, goalTextFor } from '../content/survivors';
import { LOCATION_TEMPLATES } from '../content/locations';
import { WEATHER } from '../content/world';
export const SAVE_VERSION = 3;
export function emptyRes(): Record<ResourceId, number> {
return { food: 0, water: 0, medicine: 0, fuel: 0, materials: 0, ammo: 0, tools: 0 };
}
export function rollWeather(rng: import('./types').Rng, day: number, prev?: string): import('./types').WeatherId {
const table: [import('./types').WeatherId, number][] =
day < 15
? [['clear', 34], ['rain', 20], ['fog', 14], ['storm', 10], ['heat', 12], ['cold', 10]]
: [['clear', 26], ['rain', 18], ['fog', 13], ['storm', 16], ['heat', 11], ['cold', 16]];
let w = rng.weighted(table, t => t[1])[0];
if (prev && rng.chance(0.45)) w = prev as import('./types').WeatherId;
return w;
}
export function genLocations(rng: import('./types').Rng): LocationInst[] {
const out: LocationInst[] = [];
let uidN = 1;
const usedSpecials = new Set<string>();
for (const t of LOCATION_TEMPLATES) {
const inst: LocationInst = {
uid: `L${uidN++}`,
templateId: t.id,
name: t.name,
tier: t.tier,
risk: Math.max(5, Math.min(90, t.risk + rng.i(-8, 8))),
lootPool: Math.round(t.lootUnits === 99 ? 999 : t.lootUnits),
searches: 0,
discovered: false,
specialFound: false,
cleared: false,
};
// radio parts are guaranteed at their themed sites (progression-critical);
// other specials are probabilistic and unique across the world
const hasRadio = t.specials.some(sp => sp.id === 'radio_part');
if (t.specials.length && (hasRadio || rng.chance(0.85))) {
const sp = t.specials.find(x => x.id === 'radio_part' || !usedSpecials.has(x.id));
if (sp) { inst.specialId = sp.id; usedSpecials.add(sp.id); }
}
out.push(inst);
}
return out;
}
export function createNewGame(seed?: number): GameState {
const sd = seed ?? randomSeed();
const rng = createRng(sd);
// --- Starting survivors: a leader + three others, distinct occupations ---
const survivors = [];
const lead = generateSurvivor(rng, 's1', { occId: 'leader', joinDay: 1 });
survivors.push(lead);
const occPool = ['doctor', 'hunter', 'engineer', 'scavenger', 'soldier', 'nurse', 'farmer', 'mechanic'];
const picked = rng.pickN(occPool, 3);
picked.forEach((occ, i) => {
const s = generateSurvivor(rng, `s${i + 2}`, { occId: occ, joinDay: 1 });
survivors.push(s);
});
// goal personalization: protectPerson targets a companion
for (const s of survivors) {
if (s.goalId === 'protectPerson') {
const other = rng.pick(survivors.filter(x => x.id !== s.id));
s.goalText = goalTextFor(s, other);
}
s.memories.push({ day: 0, text: 'Made it out of the city with strangers who became, against all odds, a group.', tone: 'neutral' });
}
// starter relationships
const rel: Record<string, number> = {};
for (let i = 0; i < survivors.length; i++) {
for (let j = i + 1; j < survivors.length; j++) {
rel[`${survivors[i].id}|${survivors[j].id}`] = rng.i(-10, 25);
}
}
const locations = genLocations(rng);
// starting knowledge: three tier-1 sites
const starters = rng.shuffle(locations.filter(l => l.tier === 1)).slice(0, 3);
starters.forEach(l => { l.discovered = true; });
const weather = rollWeather(rng, 1);
const tomorrowWeather = rollWeather(rng, 1, weather);
const res = emptyRes();
res.food = rng.i(14, 24);
res.water = rng.i(16, 26);
res.medicine = rng.i(2, 5);
res.fuel = rng.i(2, 6);
res.materials = rng.i(8, 16);
res.ammo = rng.i(0, 4);
res.tools = rng.i(0, 2);
const s: GameState = {
version: SAVE_VERSION,
seed: sd,
rngCursor: rng.cursor(),
day: 1,
phase: 'planning',
ap: 3,
apMax: 3,
res,
survivors,
nextSurvivorNum: survivors.length + 1,
camp: { buildings: { shelter: 1 }, integrity: 82 },
locations,
groups: [],
weather,
tomorrowWeather,
rel,
log: [],
flags: {},
strFlags: {},
queuedEvents: [],
seenEvents: {},
stats: { scavengedRuns: 0, kills: 0, lost: 0, helpedStrangers: 0, robbed: 0, tradesDone: 0, nightsStorm: 0 },
merchantToday: false,
endingId: null,
pending: [],
};
s.log.push({ day: 0, kind: 'story', text: `The Grayfall came for nine days. On the tenth, the living walked out of the cities and started counting.\n\nYou are Day 1. There will be a hundred.` });
s.log.push({ day: 1, kind: 'info', text: `${WEATHER[weather].icon} ${WEATHER[weather].label}${WEATHER[weather].desc}` });
// give one starter weapon if ammo/tools allow flavor
const soldier = survivors.find(x => x.occ === 'soldier');
if (soldier) soldier.weapon = res.ammo >= 2 ? 'pistol' : 'bat';
const hunter = survivors.find(x => x.occ === 'hunter');
if (hunter && !hunter.weapon) hunter.weapon = 'rifle';
const mechanic = survivors.find(x => x.occ === 'mechanic');
if (mechanic && !mechanic.weapon) mechanic.weapon = 'crowbar';
return s;
}
+383
View File
@@ -0,0 +1,383 @@
/* ============================================================
100 DAYS AFTER — shared type definitions
State is plain serializable data. All logic lives in
engine/ (simulation) and content/ (data).
============================================================ */
export type ResourceId =
| 'food' | 'water' | 'medicine' | 'fuel' | 'materials' | 'ammo' | 'tools';
export const RESOURCE_IDS: ResourceId[] =
['food', 'water', 'medicine', 'fuel', 'materials', 'ammo', 'tools'];
export type SkillId =
| 'combat' | 'scavenge' | 'hunt' | 'engineering' | 'medicine' | 'charisma';
export const SKILL_IDS: SkillId[] =
['combat', 'scavenge', 'hunt', 'engineering', 'medicine', 'charisma'];
export const SKILL_LABEL: Record<SkillId, string> = {
combat: 'Combat', scavenge: 'Scavenging', hunt: 'Hunting',
engineering: 'Engineering', medicine: 'Medicine', charisma: 'Charisma',
};
export type WeatherId = 'clear' | 'rain' | 'storm' | 'fog' | 'cold' | 'heat';
export type TraitId =
| 'compassionate' | 'brave' | 'cowardly' | 'greedy' | 'optimist'
| 'pessimist' | 'hardworker' | 'lazy' | 'hotheaded' | 'calm'
| 'loyal' | 'ambitious' | 'quiet' | 'funny' | 'greenThumb'
| 'ironStomach' | 'nightOwl' | 'fragile' | 'tough' | 'sharpEye';
export type PersonalityId =
| 'protector' | 'caretaker' | 'loner' | 'joker' | 'schemer' | 'believer';
export type GoalId =
| 'keepAlive' | 'findFamily' | 'reachCoast' | 'atone'
| 'rebuild' | 'surviveAtAnyCost' | 'protectPerson';
export interface Injury {
kind: 'cut' | 'sprain' | 'fracture' | 'burn' | 'bite' | 'illness';
label: string;
severity: number; // 1..3
daysLeft: number;
}
export interface Memory {
day: number;
text: string;
tone: 'good' | 'bad' | 'neutral';
}
export interface Survivor {
id: string;
name: string;
age: number;
occ: string; // OccupationDef.id
occLabel: string;
skills: Record<SkillId, number>;
traitIds: TraitId[];
personality: PersonalityId;
personalityLabel: string;
goalId: GoalId;
goalText: string;
hp: number; // 0..100
hunger: number; // 100 = well fed .. 0 = starving
morale: number; // 0..100
injuries: Injury[];
sick: boolean;
weapon: string | null; // weapon def id
memories: Memory[];
portraitSeed: number;
look: LookParams;
alive: boolean;
/** Left the group voluntarily or by exile (distinct from death). */
gone?: boolean;
goneDay?: number;
goneReason?: string;
coupleWith?: string; // survivor id
deathDay?: number;
deathCause?: string;
joinedDay: number;
restedTonight?: boolean;
}
export interface LookParams {
skin: number; // palette index
hair: number; // style index
hairColor: number;
coat: number; // clothing palette index
accent: string; // signature color (hex)
build: number; // 0 slim .. 2 broad
faceHair: number; // -1 none, else style idx
brow: number;
}
/* ---------------- Locations ---------------- */
export interface LocationInst {
uid: string;
templateId: string;
name: string;
tier: 1 | 2 | 3; // distance from camp
risk: number; // 0..100 current
lootPool: number; // remaining loot units
searches: number;
discovered: boolean;
specialFound: boolean;
specialId?: string; // unique discovery (radio part, vehicle…)
cleared: boolean;
}
/* ---------------- Camp ---------------- */
export interface CampState {
buildings: Record<string, number>; // buildingId -> level (0 = not built)
integrity: number; // 0..100
}
/* ---------------- Factions ---------------- */
export interface GroupState {
id: string;
name: string;
disposition: number; // -100 hostile .. +100 ally
strength: number;
metDay: number;
status: 'neutral' | 'friendly' | 'hostile' | 'destroyed' | 'gone';
}
/* ---------------- Log ---------------- */
export interface LogEntry {
day: number;
text: string;
kind: 'info' | 'good' | 'bad' | 'story' | 'combat';
}
/* ---------------- Game state ---------------- */
export type GamePhase = 'planning' | 'resolution' | 'gameover';
export interface GameState {
version: number;
seed: number;
rngCursor: number;
day: number; // 1..100
phase: GamePhase;
ap: number;
apMax: number;
res: Record<ResourceId, number>;
survivors: Survivor[];
nextSurvivorNum: number;
camp: CampState;
locations: LocationInst[];
groups: GroupState[];
weather: WeatherId;
tomorrowWeather: WeatherId;
rel: Record<string, number>; // "idA|idB" sorted -> -100..100
log: LogEntry[];
flags: Record<string, number>; // numeric flags/counters/timers
strFlags: Record<string, string>; // string flags (names, ids)
queuedEvents: QueuedEvent[];
seenEvents: Record<string, number>; // eventId -> day seen
stats: {
scavengedRuns: number;
kills: number;
lost: number;
helpedStrangers: number;
robbed: number;
tradesDone: number;
nightsStorm: number;
};
merchantToday: boolean;
endingId: string | null;
/** Interactive encounters awaiting player decision (serializable). */
pending: Encounter[];
}
export interface QueuedEvent {
eventId: string;
dueDay: number;
payloadName?: string; // e.g. name of stranger who was spared
payloadId?: string; // survivor/group uid involved
}
/* ============================================================
CONTENT DEFINITIONS (data-driven)
============================================================ */
export interface WeaponDef {
id: string;
name: string;
power: number; // combat bonus
usesAmmo: boolean;
desc: string;
}
export interface LootEntry {
res: ResourceId;
min: number;
max: number;
weight: number;
}
export interface SpecialDiscovery {
id: string;
name: string;
text: string;
}
export interface LocationTemplate {
id: string;
name: string;
short: string; // one-line flavor
risk: number; // base risk 0..100
tier: 1 | 2 | 3;
lootUnits: number; // total searchable loot
richness: number; // multiplier on loot rolls
loot: LootEntry[];
eventTags: string[]; // preferred event tags
enemyChance: number; // 0..1 per search
enemyKind: string; // 'looter' | 'dog' | 'militant' | 'feral' | 'none'
specials: SpecialDiscovery[];
props: string; // scene renderer key
}
export interface BuildingLevel {
cost: Partial<Record<ResourceId, number>>;
desc: string;
}
export interface BuildingDef {
id: string;
name: string;
icon: string;
maxLevel: number;
levels: BuildingLevel[]; // index = level-1
effectText: string;
}
export interface OccupationDef {
id: string;
label: string;
skills: Partial<Record<SkillId, number>>; // base offsets applied around midpoint
favoredTraits: TraitId[];
goalBias: GoalId[];
}
export interface EnemyDef {
kind: string;
name: string;
strength: number;
perception: number; // counters hiding
speed: number; // counters running
greed: number; // negotiate demand
loot: LootEntry[];
desc: string;
}
/* ---------------- Events ---------------- */
export interface EventChoiceReq {
res?: Partial<Record<ResourceId, number>>;
skill?: { skill: SkillId; min: number };
trait?: TraitId;
flag?: string; // must be truthy
noFlag?: string;
minFlag?: { key: string; min: number };
aliveMin?: number;
}
export interface EventChoice {
label: string;
tip?: string;
req?: EventChoiceReq;
/** Returns narration; mutates state via ctx. */
run: (ctx: EventCtx) => void;
}
export interface EventDef {
id: string;
title: string;
tag: string; // category
weight: number; // random pool weight (0 = scripted only)
minDay?: number;
maxDay?: number;
once?: boolean;
cond?: (s: GameState) => boolean;
/** Where the event happens / who is involved. */
cast?: (s: GameState) => { speaker?: Survivor | null; extras?: string[] } | void;
text: (s: GameState, cast: EventCast) => string;
choices: EventChoice[];
music?: 'tense' | 'sad' | 'hopeful' | 'dark';
}
export interface EventCast {
speaker: Survivor | null;
extras: string[];
}
/** Passed to event choice handlers. Mutating helpers record narration. */
export interface EventCtx {
s: GameState;
rng: Rng;
cast: EventCast;
say: (text: string, tone?: LogEntry['kind']) => void;
gain: (res: ResourceId, n: number) => void;
lose: (res: ResourceId, n: number) => void;
hasRes: (res: ResourceId, n: number) => boolean;
morale: (target: Survivor | 'all', delta: number) => void;
hp: (target: Survivor, delta: number) => void;
hurt: (target: Survivor, kind: Injury['kind'], label: string, sev: number, days: number) => void;
healInjury: (target: Survivor) => boolean;
rel: (a: string, b: string, delta: number) => void;
memory: (target: Survivor, text: string, tone?: Memory['tone']) => void;
flag: (key: string, v?: number) => void;
discoverRandom: () => LocationInst | null;
queueEvent: (eventId: string, inDays: number, payload?: { name?: string; id?: string }) => void;
joinGroup: (opts?: { name?: string }) => Survivor | null;
leaveGroup: (survivor: Survivor, reason: string) => void;
kill: (survivor: Survivor, cause: string) => void;
encounter: (spec: {
enemyName: string;
enemyDesc: string;
strength: number;
perception: number;
speed: number;
greed: number;
loot: LootEntry[];
party: string[];
intro: string;
context?: import('./types').Encounter['context'];
}) => void;
logWorld: (text: string, tone?: LogEntry['kind']) => void;
}
/* ---------------- RNG ---------------- */
export interface Rng {
f: () => number; // [0,1)
i: (min: number, max: number) => number;// inclusive
chance: (p: number) => boolean;
pick: <T>(arr: T[]) => T;
pickN: <T>(arr: T[], n: number) => T[];
shuffle: <T>(arr: T[]) => T[];
weighted: <T>(items: T[], w: (t: T) => number) => T;
cursor: () => number;
}
/* ---------------- Encounters (tactical combat) ---------------- */
export interface Encounter {
enemyName: string;
enemyDesc: string;
strength: number; // 0..100+
perception: number;
speed: number;
greed: number; // resources demanded (negotiate)
lootOnWin: LootEntry[];
party: string[]; // survivor ids present
intro: string;
context: 'scavenge' | 'hunt' | 'explore' | 'travel' | 'camp' | 'event';
originEvent?: string;
}
export interface EncounterResultLines {
lines: { text: string; tone: LogEntry['kind'] }[];
}
+12
View File
@@ -0,0 +1,12 @@
import { App } from './ui/ui';
import './styles.css';
const app = new App();
app.mount(document.getElementById('app')!);
// resume audio on first interaction (browser policy)
const kick = () => {
window.dispatchEvent(new Event('dsh-audio-ready'));
window.removeEventListener('pointerdown', kick);
};
window.addEventListener('pointerdown', kick);
+918
View File
@@ -0,0 +1,918 @@
/* ============================================================
100 DAYS AFTER — visual identity
dark post-apocalyptic · illustrated · cinematic
============================================================ */
:root {
--bg: #0a0d12;
--panel: #11161e;
--panel2: #151b24;
--line: #222b37;
--line2: #2d3846;
--ink: #d9dfe7;
--muted: #83909d;
--faint: #5a6673;
--ember: #e0784a;
--gold: #d9b36a;
--teal: #5da9a1;
--red: #c95d63;
--green: #6fa06b;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
--radius: 12px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
font-size: 14px;
overflow: hidden;
}
#app { height: 100vh; }
h1, h2, h3 { margin: 0; }
button { font-family: inherit; }
hr { border: none; border-top: 1px solid var(--line); margin: 12px 0; }
.muted { color: var(--muted); }
.small { font-size: 11px; }
.center { text-align: center; }
.end { justify-content: flex-end; }
.grow { flex: 1; }
.bad { color: var(--red); }
.pos { color: var(--green); }
.hidden { display: none !important; }
.row { display: flex; align-items: center; gap: 10px; }
.row.wrap { flex-wrap: wrap; }
.row.gap { gap: 10px; }
/* ---------------- tooltips ---------------- */
.tip {
position: relative;
}
.tip::after {
content: attr(data-tip);
position: absolute;
left: 0;
top: calc(100% + 7px);
background: #1a212c;
color: var(--ink);
border: 1px solid var(--line2);
padding: 7px 10px;
font-size: 11.5px;
line-height: 1.45;
width: max-content;
max-width: 250px;
white-space: pre-line;
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
transition: 0.16s ease;
z-index: 60;
box-shadow: var(--shadow);
border-radius: 8px;
}
.tip:hover::after { opacity: 1; transform: translateY(0); }
.weather-chip .tip::after { right: auto; }
/* ---------------- buttons ---------------- */
.btn {
appearance: none;
background: linear-gradient(180deg, #20293a, #18202c);
color: var(--ink);
border: 1px solid var(--line2);
padding: 9px 16px;
border-radius: 9px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.04em;
transition: transform 0.08s ease, border-color 0.15s, box-shadow 0.15s;
}
.btn:hover:not(:disabled) { border-color: #48586c; transform: translateY(-1px); }
.btn:active:not(:disabled) { transform: translateY(0); }
.btn.primary {
background: linear-gradient(180deg, #b05f38, #8f4526);
border-color: #c97a4e;
color: #fff2e6;
box-shadow: 0 4px 18px rgba(224, 120, 74, 0.25);
}
.btn.primary:hover { box-shadow: 0 6px 24px rgba(224, 120, 74, 0.4); }
.btn.danger { background: linear-gradient(180deg, #7e3438, #63272b); border-color: #a44a4f; color: #ffe9ea; }
.btn.ghost { background: transparent; }
.btn.small { padding: 5px 10px; font-size: 12px; }
.btn.big { padding: 13px 26px; font-size: 15px; letter-spacing: 0.12em; }
.btn.wide { width: 100%; margin-top: 12px; }
.btn.choice { min-width: 130px; justify-content: center; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ============================================================
TITLE SCREEN
============================================================ */
.title-screen {
position: fixed;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
}
.title-bg {
position: absolute;
inset: 0;
overflow: hidden;
}
#title-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: saturate(0.85) brightness(0.75);
}
.title-bg::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 50% 30%, transparent 20%, rgba(5, 7, 10, 0.82) 80%),
linear-gradient(180deg, rgba(5, 7, 10, 0.55), rgba(5, 7, 10, 0.35) 40%, rgba(5, 7, 10, 0.9));
}
.title-inner {
position: relative;
text-align: center;
max-width: 720px;
padding: 32px;
animation: rise 0.9s ease both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: none; }
}
.title-kicker {
color: var(--gold);
letter-spacing: 0.42em;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 14px;
text-shadow: 0 2px 12px #000;
}
.logo {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(52px, 9vw, 96px);
line-height: 0.95;
letter-spacing: 0.02em;
color: #ecf1f6;
text-shadow:
0 0 40px rgba(224, 120, 74, 0.28),
0 4px 0 #14181f,
0 14px 44px rgba(0, 0, 0, 0.9);
}
.logo span {
color: var(--ember);
font-style: italic;
}
.title-sub {
color: var(--muted);
max-width: 520px;
margin: 18px auto 30px;
line-height: 1.6;
font-size: 15px;
}
.title-menu {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.title-menu .btn.big {
min-width: 300px;
background: rgba(17, 22, 30, 0.82);
backdrop-filter: blur(6px);
}
.title-foot {
margin-top: 34px;
color: var(--faint);
font-size: 11.5px;
letter-spacing: 0.08em;
}
.seed-input {
width: 100%;
background: #0d1219;
color: var(--ink);
border: 1px solid var(--line2);
border-radius: 8px;
padding: 10px 12px;
margin: 10px 0;
font-size: 14px;
}
.seed-input:focus { outline: 1px solid var(--ember); border-color: var(--ember); }
.form-error { color: var(--red); font-size: 12px; min-height: 16px; margin-bottom: 6px; }
/* ============================================================
GAME LAYOUT
============================================================ */
.game-layout {
display: grid;
grid-template-columns: 300px 1fr 320px;
grid-template-rows: 56px 1fr;
grid-template-areas:
"top top top"
"left center right";
height: 100vh;
}
/* ---------------- topbar ---------------- */
.topbar {
grid-area: top;
display: flex;
align-items: center;
gap: 14px;
padding: 0 16px;
background: linear-gradient(180deg, #131924, #10151d);
border-bottom: 1px solid var(--line);
z-index: 20;
}
.brand-mini {
font-family: Georgia, serif;
letter-spacing: 0.14em;
color: var(--faint);
font-size: 11px;
white-space: nowrap;
}
.day-chip {
font-size: 15px;
letter-spacing: 0.06em;
color: var(--ink);
white-space: nowrap;
}
.day-chip b { color: var(--gold); font-size: 19px; }
.day-chip .dim { color: var(--faint); font-size: 12px; }
.weather-chip {
display: flex;
align-items: center;
gap: 7px;
background: var(--panel2);
border: 1px solid var(--line);
padding: 5px 11px;
border-radius: 999px;
font-size: 12.5px;
cursor: default;
}
.w-icon { font-size: 15px; }
.res-row {
display: flex;
gap: 8px;
flex: 1;
flex-wrap: wrap;
}
.res-chip {
display: flex;
align-items: center;
gap: 6px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px 9px;
font-size: 13px;
}
.res-chip b { font-variant-numeric: tabular-nums; }
.res-chip.low { border-color: #7e3438; animation: lowpulse 1.6s infinite; }
@keyframes lowpulse { 50% { box-shadow: 0 0 12px rgba(201, 93, 99, 0.35); } }
.ap-wrap { display: flex; gap: 5px; align-items: center; }
.ap-pip {
width: 11px;
height: 20px;
border-radius: 4px;
background: #1a212c;
border: 1px solid var(--line2);
transition: 0.25s;
}
.ap-pip.full {
background: linear-gradient(180deg, var(--gold), #a9823f);
border-color: var(--gold);
box-shadow: 0 0 10px rgba(217, 179, 106, 0.4);
}
.menu-wrap { display: flex; }
/* ---------------- panels ---------------- */
.panel {
background: var(--panel);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #2a3340 transparent;
}
.panel.left { grid-area: left; border-right: 1px solid var(--line); padding: 12px; }
.panel.right { grid-area: right; border-left: 1px solid var(--line); padding: 0; }
.panel-title {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
letter-spacing: 0.18em;
color: var(--muted);
margin: 4px 4px 12px;
}
.phase-chip {
font-size: 10px;
color: var(--gold);
border: 1px solid #57431f;
background: #1d1812;
padding: 2px 8px;
border-radius: 999px;
letter-spacing: 0.1em;
}
/* survivors list */
.surv-card {
display: flex;
gap: 10px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 9px;
margin-bottom: 9px;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.surv-card:hover { border-color: #3d4b5e; transform: translateX(2px); }
.surv-card.dead { opacity: 0.55; filter: grayscale(0.7); cursor: default; }
.surv-card .port { flex-shrink: 0; }
.surv-card .port svg { border-radius: 8px; display: block; }
.s-main { flex: 1; min-width: 0; }
.s-name { font-weight: 700; font-size: 13.5px; display: flex; align-items: center; gap: 6px; }
.wpn { color: var(--faint); font-weight: 400; font-size: 10.5px; }
.s-sub { color: var(--muted); font-size: 11px; margin: 1px 0 5px; }
.bars { display: flex; flex-direction: column; gap: 3px; }
.bar {
height: 5px;
border-radius: 3px;
background: #0d1118;
overflow: hidden;
position: relative;
}
.bar i { display: block; height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.bar.labeled { height: 14px; display: flex; align-items: center; margin: 4px 0; }
.bar.labeled em {
position: absolute;
left: 6px;
font-style: normal;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(230, 235, 240, 0.75);
text-shadow: 0 1px 2px #000;
}
.status-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.chip {
font-size: 10px;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--line2);
color: var(--muted);
background: rgba(255, 255, 255, 0.02);
}
.chip.on { color: var(--gold); border-color: #57431f; }
.chip.warn { color: var(--gold); border-color: #6b5525; }
.chip.bad { color: var(--red); border-color: #7e3438; }
.chip.good { color: var(--green); border-color: #3c5a39; }
.chip.love { color: #e58aa0; border-color: #7e3a4c; }
/* morale meter */
.morale-meter {
display: flex;
align-items: center;
gap: 9px;
margin-top: 14px;
padding: 10px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.mm-label { font-size: 9.5px; letter-spacing: 0.14em; color: var(--faint); }
.mm-bar { flex: 1; height: 7px; background: #0d1118; border-radius: 4px; overflow: hidden; }
.mm-fill {
height: 100%;
background: linear-gradient(90deg, #7e3438, #d9a441, #5da9a1);
border-radius: 4px;
transition: width 0.6s ease;
}
/* ---------------- center / scene ---------------- */
.center { grid-area: center; display: flex; flex-direction: column; min-width: 0; }
.scene-holder {
flex: 1;
position: relative;
min-height: 220px;
overflow: hidden;
background: #000;
}
#scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.actionbar {
display: flex;
gap: 8px;
padding: 10px 12px;
background: linear-gradient(180deg, #10151d, #0d1118);
border-top: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: thin;
}
.action {
position: relative;
flex: 1;
min-width: 92px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 9px 6px 8px;
background: linear-gradient(180deg, #1a2230, #141b26);
border: 1px solid var(--line2);
border-radius: 10px;
color: var(--ink);
cursor: pointer;
transition: 0.15s;
}
.action b { font-size: 12px; letter-spacing: 0.08em; }
.action span { font-size: 10px; color: var(--muted); }
.action:hover:not(.disabled) {
border-color: var(--ember);
box-shadow: 0 4px 18px rgba(224, 120, 74, 0.18);
transform: translateY(-2px);
}
.action.disabled { opacity: 0.38; cursor: not-allowed; }
.action.primary { border-color: #6b4a2e; }
/* ---------------- right panel tabs ---------------- */
.tabs { display: flex; border-bottom: 1px solid var(--line); }
.tab {
flex: 1;
background: none;
border: none;
color: var(--muted);
padding: 11px;
font-size: 11.5px;
letter-spacing: 0.16em;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active { color: var(--gold); border-bottom-color: var(--gold); }
.tab-body { padding: 12px; overflow-y: auto; height: calc(100% - 41px); }
.log-day {
font-size: 9.5px;
letter-spacing: 0.22em;
color: var(--faint);
margin: 12px 0 6px;
display: flex;
align-items: center;
gap: 8px;
}
.log-day::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.log-line {
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
padding: 3px 0 3px 10px;
border-left: 2px solid var(--line);
margin-bottom: 4px;
}
.log-line.good { color: #a8c2a4; border-left-color: #3c5a39; }
.log-line.bad { color: #cf8d91; border-left-color: #7e3438; }
.log-line.story { color: var(--gold); border-left-color: #57431f; }
.log-line.combat { color: #e0a49a; border-left-color: #8f4526; }
/* map */
.map-tier-label {
font-size: 10px;
letter-spacing: 0.2em;
color: var(--muted);
margin: 14px 0 8px;
}
.map-entry {
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 10px;
margin-bottom: 7px;
transition: border-color 0.15s;
}
.map-entry.unknown { opacity: 0.55; font-style: italic; }
.map-entry.static { cursor: default; }
.map-entry:not(.unknown):hover { border-color: #3d4b5e; }
.map-line1 { display: flex; align-items: center; gap: 8px; font-size: 12.5px; }
.risk { color: var(--ember); font-size: 10px; letter-spacing: 2px; }
.sp-mark { color: var(--gold); }
.cleared-tag { margin-left: auto; font-size: 10px; color: var(--faint); }
.map-loot { height: 4px; background: #0d1118; border-radius: 3px; margin-top: 7px; overflow: hidden; }
.map-loot div { height: 100%; background: linear-gradient(90deg, #57431f, var(--gold)); }
.bldg-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.int-bar { flex: 1; height: 6px; background: #0d1118; border-radius: 3px; overflow: hidden; }
.int-bar div { height: 100%; background: linear-gradient(90deg, var(--red), var(--green)); }
/* ============================================================
MODALS
============================================================ */
.modal-root {
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(4, 6, 9, 0.68);
backdrop-filter: blur(3px);
display: grid;
place-items: center;
padding: 24px;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
overflow-y: auto;
}
.overlay.show { opacity: 1; pointer-events: auto; }
.modal-card {
width: min(680px, 94vw);
max-height: 88vh;
overflow-y: auto;
background: linear-gradient(180deg, #161d27, #10151d);
border: 1px solid var(--line2);
border-radius: 16px;
box-shadow: var(--shadow);
animation: cardIn 0.24s cubic-bezier(0.2, 0.9, 0.3, 1.2);
scrollbar-width: thin;
}
@keyframes cardIn {
from { transform: translateY(16px) scale(0.98); opacity: 0; }
to { transform: none; opacity: 1; }
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px 10px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 {
font-size: 13px;
letter-spacing: 0.24em;
color: var(--gold);
font-weight: 600;
}
.modal-x {
background: none;
border: none;
color: var(--muted);
font-size: 15px;
cursor: pointer;
padding: 4px 8px;
}
.modal-x:hover { color: var(--ink); }
.modal-card > div:not(.modal-head) { padding: 16px 18px; }
.modal-card p { line-height: 1.62; }
.report .rep-line { margin: 0 0 10px; }
.rep-line.good { color: #b5cdb0; }
.rep-line.bad { color: #dba0a3; }
.rep-line.combat { color: #e5b3a6; }
.rep-line.story { color: var(--gold); }
.field-label {
font-size: 10px;
letter-spacing: 0.2em;
color: var(--faint);
margin: 14px 0 7px;
}
/* party picker */
.party-check { margin-top: 6px; }
.check-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 11px;
margin-bottom: 6px;
cursor: pointer;
}
.check-row:hover { border-color: #3d4b5e; }
.check-row input { accent-color: var(--ember); }
.pc-name { font-weight: 700; min-width: 70px; }
.pc-skill { color: var(--teal); font-size: 11.5px; }
.pc-cond { margin-left: auto; font-size: 11px; color: var(--muted); }
.site-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.site-card {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
cursor: pointer;
transition: 0.14s;
}
.site-card:hover { border-color: #3d4b5e; }
.site-card.sel { border-color: var(--ember); box-shadow: 0 0 0 1px var(--ember), 0 4px 16px rgba(224, 120, 74, 0.2); }
.site-card.locked { opacity: 0.4; cursor: not-allowed; }
.site-meta { font-size: 11px; color: var(--teal); }
.picker-list { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
.pick-row {
display: flex;
gap: 11px;
align-items: center;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 7px 11px;
cursor: pointer;
transition: border-color 0.14s;
}
.pick-row:hover { border-color: var(--ember); }
.pick-row svg { border-radius: 6px; flex-shrink: 0; }
.rel-val.pos { color: var(--green); }
.rel-val.neg { color: var(--red); }
.mood-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); vertical-align: middle; }
.mood-dot.happy { background: var(--green); }
.mood-dot.sad { background: #7f95c9; }
.mood-dot.angry { background: var(--red); }
.mood-dot.hurt { background: var(--gold); }
/* build menu */
.build-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.build-card {
border: 1px solid var(--line);
border-radius: 11px;
background: var(--panel2);
padding: 12px;
cursor: pointer;
transition: 0.15s;
}
.build-card:hover:not(.off) { border-color: var(--ember); transform: translateY(-2px); }
.build-card.off { opacity: 0.5; cursor: not-allowed; }
.bc-head { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.bc-icon { font-size: 17px; }
.lv-pips { margin-left: auto; display: flex; gap: 3px; }
.lv-pips i { width: 7px; height: 7px; border-radius: 50%; background: #232c38; }
.lv-pips i.on { background: var(--gold); }
.bc-desc { font-size: 11.5px; color: var(--muted); margin: 7px 0 4px; line-height: 1.5; min-height: 34px; }
.bc-cost { display: flex; gap: 9px; font-size: 12px; margin-top: 6px; }
.bc-cost .lack { color: var(--red); }
.bc-cost .ok { color: var(--green); }
.trade-row {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 9px;
padding: 9px 12px;
margin-bottom: 7px;
}
.save-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
}
.save-info { flex: 1; line-height: 1.5; }
.help p { line-height: 1.65; }
/* ============================================================
EVENTS
============================================================ */
.event-modal .modal-card { width: min(760px, 95vw); }
.event-body { display: grid; grid-template-columns: 128px 1fr; gap: 18px; }
.event-port { text-align: center; }
.event-port svg { border-radius: 12px; box-shadow: var(--shadow); }
.event-port-name {
margin-top: 7px;
font-size: 12px;
letter-spacing: 0.14em;
color: var(--gold);
text-transform: uppercase;
}
.stranger-sil {
width: 110px;
height: 132px;
border-radius: 12px;
background: radial-gradient(circle at 50% 30%, #1d242e, #0c0f15);
border: 1px solid var(--line2);
display: grid;
place-items: center;
font-size: 46px;
filter: grayscale(0.6) brightness(0.8);
box-shadow: var(--shadow);
}
.event-text {
line-height: 1.72;
font-size: 14.5px;
color: #ccd5de;
}
.event-choices { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
.ev-choice {
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
background: linear-gradient(180deg, #1b2330, #151c27);
border: 1px solid var(--line2);
border-radius: 10px;
color: var(--ink);
padding: 11px 14px;
cursor: pointer;
transition: 0.14s;
}
.ev-choice span { font-weight: 700; font-size: 13.5px; letter-spacing: 0.03em; }
.ev-choice em { font-style: normal; font-size: 11px; color: var(--gold); }
.ev-choice u { text-decoration: none; font-size: 11px; color: var(--muted); }
.ev-choice:hover:not(.locked) { border-color: var(--ember); transform: translateX(4px); }
.ev-choice.locked { opacity: 0.45; cursor: not-allowed; }
/* encounters */
.enc-modal .modal-card { width: min(640px, 94vw); }
.enc-title {
font-family: Georgia, serif;
font-size: 26px;
color: #e8d9c8;
text-align: center;
text-shadow: 0 2px 18px rgba(224, 120, 74, 0.3);
}
.enc-desc { text-align: center; color: var(--muted); font-style: italic; }
.enc-intro { text-align: center; line-height: 1.65; }
.enc-party { text-align: center; }
.odds-row {
display: flex;
justify-content: space-around;
background: #0d1219;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
margin: 12px 0;
}
.odd-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 11.5px; color: var(--muted); }
.odd-num { font-size: 21px; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
/* ============================================================
SURVIVOR DETAIL
============================================================ */
.sd-body { display: grid; grid-template-columns: 160px 1fr; gap: 18px; }
.sd-left { text-align: center; }
.sd-left svg { border-radius: 12px; box-shadow: var(--shadow); }
.sd-name { font-size: 19px; margin-bottom: 2px; }
.sd-personality { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; }
.sd-goal { font-size: 13px; color: var(--gold); margin-bottom: 10px; }
.sd-vitals { display: flex; flex-direction: column; }
.sd-vitals .bar.labeled { background: #0d1118; }
.skill-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 14px; }
.skill { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
.skill span { width: 84px; }
.sk-bar { flex: 1; height: 5px; background: #0d1118; border-radius: 3px; overflow: hidden; }
.sk-bar i { display: block; height: 100%; background: linear-gradient(90deg, #3d5a56, var(--teal)); }
.trait-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.rel-list { display: flex; flex-direction: column; gap: 6px; }
.rel-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; }
.rel-row b { width: 60px; }
.rel-bar { flex: 1; height: 6px; background: #0d1118; border-radius: 3px; overflow: hidden; display: flex; justify-content: center; }
.rel-bar i { display: block; height: 100%; }
.rel-bar i.pos { background: var(--green); }
.rel-bar i.neg { background: var(--red); }
.memory-list { display: flex; flex-direction: column; gap: 5px; max-height: 190px; overflow-y: auto; }
.memory { font-size: 12px; color: var(--muted); line-height: 1.5; padding-left: 8px; border-left: 2px solid var(--line); }
.memory.good { color: #a8c2a4; }
.memory.bad { color: #cf8d91; }
.mem-day { color: var(--faint); font-weight: 700; margin-right: 7px; font-size: 10.5px; }
.inj-row { display: flex; justify-content: space-between; font-size: 12px; background: var(--panel2); border-radius: 8px; padding: 6px 10px; margin-bottom: 5px; border-left: 3px solid var(--gold); }
.inj-row.sev3 { border-left-color: var(--red); }
/* ============================================================
ENDINGS
============================================================ */
.ending-modal .overlay { background: rgba(3, 4, 7, 0.9); }
.ending-modal .modal-card { width: min(760px, 95vw); }
.ending-body { text-align: center; padding: 10px 6px; }
.ending-kicker { font-size: 10.5px; letter-spacing: 0.3em; color: var(--muted); }
.ending-title {
font-family: Georgia, serif;
font-size: clamp(34px, 6vw, 54px);
margin: 14px 0 20px;
letter-spacing: 0.04em;
}
.tone-good .ending-title { color: #bfd8bb; text-shadow: 0 0 34px rgba(111, 160, 107, 0.4); }
.tone-bitter .ending-title { color: #c9c2b4; }
.tone-bad .ending-title { color: #b96a6e; text-shadow: 0 0 34px rgba(126, 52, 56, 0.5); }
.tone-transcendent .ending-title { color: var(--gold); text-shadow: 0 0 44px rgba(217, 179, 106, 0.55); }
.ending-text { line-height: 1.8; font-size: 15px; color: #cdd6df; text-align: left; }
.epilogue { margin: 26px auto 8px; max-width: 480px; text-align: left; }
.ep-title { font-size: 10.5px; letter-spacing: 0.28em; color: var(--faint); margin-bottom: 9px; text-align: center; }
.ep-row {
display: flex;
gap: 12px;
justify-content: space-between;
font-size: 12.5px;
padding: 5px 2px;
border-bottom: 1px dashed #1e2631;
}
.ep-row.alive span { color: #a8c2a4; }
.ep-row.bad span { color: #cf8d91; }
.ep-row.gone span { color: var(--muted); font-style: italic; }
.end-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 9px;
margin: 20px 0;
}
.stat {
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px;
}
.stat b { font-size: 17px; display: block; }
.stat span { font-size: 9.5px; letter-spacing: 0.12em; color: var(--faint); text-transform: uppercase; }
/* toasts */
#toasts {
position: fixed;
bottom: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 90;
}
.toast {
background: #161d27ee;
border: 1px solid var(--line2);
border-left: 3px solid var(--muted);
padding: 9px 14px;
border-radius: 9px;
font-size: 12.5px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateX(24px);
transition: 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.1);
}
.toast.show { opacity: 1; transform: none; }
.toast.good { border-left-color: var(--green); }
.toast.bad { border-left-color: var(--red); }
/* ---------------- responsive ---------------- */
@media (max-width: 1080px) {
.game-layout {
grid-template-columns: 250px 1fr 260px;
}
.build-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
body { overflow: auto; }
.game-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
grid-template-areas: "top" "center" "left" "right";
height: auto;
}
.scene-holder { height: 300px; }
.sd-body, .event-body { grid-template-columns: 1fr; }
.event-port { display: flex; align-items: center; gap: 12px; justify-content: center; }
.end-stats { grid-template-columns: repeat(2, 1fr); }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+134
View File
@@ -0,0 +1,134 @@
import type { Survivor } from '../engine/types';
import { SKIN_TONES, HAIR_COLORS, COAT_COLORS } from '../content/survivors';
export type Mood = 'neutral' | 'sad' | 'angry' | 'happy' | 'hurt';
export function moodOf(s: Survivor): Mood {
const worstInj = s.injuries.reduce((m, i) => Math.max(m, i.severity), 0);
if (!s.alive) return 'sad';
if (s.hp < 35 || worstInj >= 3) return 'hurt';
if (s.sick) return 'hurt';
if (s.morale < 30) return 'sad';
if (s.morale > 76) return 'happy';
return 'neutral';
}
function shade(hex: string, amt: number): string {
const n = parseInt(hex.slice(1), 16);
const r = Math.max(0, Math.min(255, ((n >> 16) & 255) + amt));
const g = Math.max(0, Math.min(255, ((n >> 8) & 255) + amt));
const b = Math.max(0, Math.min(255, (n & 255) + amt));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
const OCC_BADGE: Record<string, string> = {
doctor: '<path d="M50 96 h6 v-6 h6 v6 h6 v6 h-6 v6 h-6 v-6 h-6 z" fill="ACCENT" opacity="0.9"/>',
nurse: '<path d="M50 94 h8 v8 h8 v8 h-8 v8 h-8 v-8 h-8 v-8 h8 z" fill="ACCENT" opacity="0.75" transform="scale(0.7) translate(21,42)"/>',
soldier: '<path d="M50 94 l5 10 11 1 -8 8 2 11 -10-5 -10 5 2-11 -8-8 11-1 z" fill="ACCENT" opacity="0.85" transform="scale(0.62) translate(30,60)"/>',
police: '<path d="M44 98 h12 v4 h-12 z M50 92 l10 6 -10 14 -10-14 z" fill="ACCENT" opacity="0.8"/>',
hunter: '<path d="M42 104 q8 -10 16 0 q-8 6 -16 0 z" fill="ACCENT" opacity="0.8"/>',
engineer: '<path d="M46 92 a8 8 0 1 0 8 14 l6 6 4-4 -6-6 a8 8 0 0 0 -12-10 z" fill="ACCENT" opacity="0.8"/>',
mechanic: '<path d="M44 94 l12 12 M56 94 l-12 12" stroke="ACCENT" stroke-width="4" opacity="0.85"/>',
scavenger: '<circle cx="50" cy="100" r="6" fill="none" stroke="ACCENT" stroke-width="3" opacity="0.85"/>',
farmer: '<path d="M50 108 v-12 M50 100 q-6 -2 -8 -8 q8 0 8 8 M50 98 q6 -4 8 -10 q-9 1 -8 10" stroke="ACCENT" stroke-width="2.5" fill="none" opacity="0.9"/>',
leader: '<path d="M42 104 l4 -10 4 6 4-10 4 14 z" fill="ACCENT" opacity="0.9"/>',
cook: '<path d="M42 102 q8 -8 16 0 z" fill="ACCENT" opacity="0.8"/>',
teacher: '<path d="M40 96 h20 M50 96 v12 M44 108 h12" stroke="ACCENT" stroke-width="3" opacity="0.8"/>',
student: '<circle cx="50" cy="101" r="5" fill="ACCENT" opacity="0.7"/>',
postal: '<path d="M42 98 h16 v10 h-16 z M42 98 l8 6 8-6" fill="none" stroke="ACCENT" stroke-width="2.5" opacity="0.85"/>',
};
function hairPath(style: number, fill: string): string {
switch (style % 6) {
case 0: return `<path d="M31 46 q0 -22 19 -22 q19 0 19 22 q-4 -12 -19 -12 q-15 0 -19 12 z" fill="${fill}"/>`;
case 1: return `<path d="M30 50 q-2 -26 20 -26 q22 0 20 26 l-3 -2 q1 -18 -17 -18 q-18 0 -17 18 z" fill="${fill}"/><path d="M30 50 q-4 16 -2 24 q4 -2 5 -10 z M70 50 q4 16 2 24 q-4 -2 -5 -10 z" fill="${fill}"/>`;
case 2: return `<path d="M32 44 q2 -20 18 -20 q16 0 18 20 q-6 -8 -18 -8 q-12 0 -18 8 z" fill="${fill}"/><circle cx="50" cy="24" r="7" fill="${fill}"/>`;
case 3: return `<path d="M32 46 l3 -14 5 8 4 -12 6 10 5 -12 5 12 5 -8 3 16 q-9 -10 -18 -10 q-9 0 -18 10 z" fill="${fill}"/>`;
case 4: return `<path d="M31 48 q0 -24 19 -24 q19 0 19 24 q-2 -14 -8 -16 q-4 6 -11 6 q-7 0 -11 -6 q-6 2 -8 16 z" fill="${fill}"/>`;
default: return `<path d="M33 42 q17 -18 34 0 q-2 10 -4 12 q0 -16 -13 -16 q-13 0 -13 16 q-2 -2 -4 -12 z" fill="${fill}"/>`;
}
}
function faceHairPath(style: number, fill: string): string {
switch (style % 4) {
case 0: return `<path d="M36 58 q14 10 28 0 q-2 10 -14 10 q-12 0 -14 -10 z" fill="${fill}" opacity="0.55"/>`;
case 1: return `<rect x="42" y="60" width="16" height="4" rx="2" fill="${fill}" opacity="0.8"/>`;
case 2: return `<path d="M36 54 q0 22 14 22 q14 0 14 -22 q-3 12 -14 12 q-11 0 -14 -12 z" fill="${fill}" opacity="0.85"/><rect x="42" y="59" width="16" height="3.4" rx="1.7" fill="${fill}" opacity="0.85"/>`;
default: return `<path d="M44 62 q6 6 12 0 l-2 10 q-4 3 -8 0 z" fill="${fill}" opacity="0.85"/>`;
}
}
/** Build the inline SVG for a survivor's portrait. */
export function portraitSVG(s: Survivor, mood?: Mood, px = 96): string {
const m = mood ?? moodOf(s);
const skin = SKIN_TONES[s.look.skin % SKIN_TONES.length];
const hairC = HAIR_COLORS[s.look.hairColor % HAIR_COLORS.length];
const coat = COAT_COLORS[s.look.coat % COAT_COLORS.length];
const accent = s.look.accent;
const buildW = [0.92, 1, 1.1][s.look.build % 3];
const dead = !s.alive;
// eyes / brows / mouth per mood
let brows = '', eyes = '', mouth = '';
const browY = 40;
const browFill = shade(hairC, 10);
if (m === 'angry') {
brows = `<path d="M38 ${browY} l10 4 M62 ${browY} l-10 4" stroke="${browFill}" stroke-width="2.6" stroke-linecap="round"/>`;
eyes = `<ellipse cx="43" cy="47" rx="3.4" ry="1.9" fill="#d8dee6"/><ellipse cx="57" cy="47" rx="3.4" ry="1.9" fill="#d8dee6"/><circle cx="43.5" cy="47.4" r="1.5" fill="#1a1d22"/><circle cx="57.5" cy="47.4" r="1.5" fill="#1a1d22"/>`;
mouth = `<path d="M44 58 q6 -3 12 0" stroke="#5b3a35" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
} else if (m === 'sad') {
brows = `<path d="M38 ${browY + 2} l10 -3 M62 ${browY + 2} l-10 -3" stroke="${browFill}" stroke-width="2.6" stroke-linecap="round"/>`;
eyes = `<ellipse cx="43" cy="47" rx="3.6" ry="2.4" fill="#d8dee6"/><ellipse cx="57" cy="47" rx="3.6" ry="2.4" fill="#d8dee6"/><circle cx="43" cy="47.6" r="1.6" fill="#1a1d22"/><circle cx="57" cy="47.6" r="1.6" fill="#1a1d22"/>`;
mouth = `<path d="M44 60 q6 -4 12 0" stroke="#5b3a35" stroke-width="2.4" fill="none" stroke-linecap="round" transform="rotate(180 50 58)"/>`;
} else if (m === 'happy') {
brows = `<path d="M38 ${browY - 1} q5 -3 10 0 M52 ${browY - 1} q5 -3 10 0" stroke="${browFill}" stroke-width="2.6" fill="none" stroke-linecap="round"/>`;
eyes = `<path d="M39 47 q4 -4 8 0 M53 47 q4 -4 8 0" stroke="#2a2e35" stroke-width="2.2" fill="none" stroke-linecap="round"/>`;
mouth = `<path d="M43 57 q7 6 14 0" stroke="#5b3a35" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
} else if (m === 'hurt') {
brows = `<path d="M38 ${browY + 1} l10 2 M62 ${browY + 1} l-10 2" stroke="${browFill}" stroke-width="2.6" stroke-linecap="round"/>`;
eyes = `<ellipse cx="43" cy="47.5" rx="3.2" ry="1.7" fill="#d8dee6"/><ellipse cx="57" cy="47.5" rx="3.2" ry="1.7" fill="#d8dee6"/><circle cx="43.4" cy="47.8" r="1.4" fill="#1a1d22"/><circle cx="57.4" cy="47.8" r="1.4" fill="#1a1d22"/>`;
mouth = `<path d="M44 59 l4 2 4 -2 4 2" stroke="#5b3a35" stroke-width="2.2" fill="none" stroke-linecap="round"/>`;
} else {
brows = `<path d="M38 ${browY} h10 M52 ${browY} h10" stroke="${browFill}" stroke-width="2.6" stroke-linecap="round"/>`;
eyes = `<ellipse cx="43" cy="47" rx="3.5" ry="2.2" fill="#d8dee6"/><ellipse cx="57" cy="47" rx="3.5" ry="2.2" fill="#d8dee6"/><circle cx="43.3" cy="47.4" r="1.5" fill="#1a1d22"/><circle cx="57.3" cy="47.4" r="1.5" fill="#1a1d22"/>`;
mouth = `<path d="M45 58.5 h10" stroke="#5b3a35" stroke-width="2.4" stroke-linecap="round"/>`;
}
const badge = (OCC_BADGE[s.occ] ?? OCC_BADGE.scavenger).replaceAll('ACCENT', accent);
const injuryMark = m === 'hurt'
? `<rect x="60" y="36" width="12" height="5" rx="2" fill="#e8e4da" opacity="0.92" transform="rotate(-14 66 38)"/><path d="M37 52 l7 3" stroke="#b0563f" stroke-width="1.6" stroke-linecap="round"/>`
: '';
const sickTint = s.sick && s.alive ? `<ellipse cx="50" cy="48" rx="18" ry="21" fill="#7fae6a" opacity="0.14"/>` : '';
const deadTint = dead ? `<rect width="100" height="120" fill="#0a0c10" opacity="0.55"/>` : '';
return `<svg viewBox="0 0 100 120" width="${px}" height="${Math.round(px * 1.2)}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${s.name}">
<defs>
<radialGradient id="pbg${s.id}" cx="50%" cy="38%" r="75%">
<stop offset="0%" stop-color="#232c36"/><stop offset="100%" stop-color="#0d1117"/>
</radialGradient>
<linearGradient id="rim${s.id}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${accent}" stop-opacity="0"/><stop offset="100%" stop-color="${accent}" stop-opacity="0.5"/>
</linearGradient>
</defs>
<rect width="100" height="120" fill="url(#pbg${s.id})"/>
<ellipse cx="50" cy="118" rx="34" ry="10" fill="#000" opacity="0.4"/>
<g ${dead ? 'opacity="0.8"' : ''}>
<path d="M${50 - 26 * buildW} 120 q${2 * buildW} -34 ${26 * buildW} -34 q${24 * buildW} 0 ${26 * buildW} 34 z" fill="${coat}"/>
<path d="M${50 - 26 * buildW} 120 q${2 * buildW} -34 ${26 * buildW} -34 l0 34 z" fill="url(#rim${s.id})" opacity="0.35"/>
<path d="M42 88 l8 8 8 -8" fill="none" stroke="${shade(coat, 26)}" stroke-width="2"/>
<rect x="45" y="74" width="10" height="12" rx="3" fill="${shade(skin, -34)}"/>
<ellipse cx="50" cy="50" rx="17.5" ry="20.5" fill="${skin}"/>
<ellipse cx="32.5" cy="50" rx="3" ry="5" fill="${shade(skin, -18)}"/>
<ellipse cx="67.5" cy="50" rx="3" ry="5" fill="${shade(skin, -18)}"/>
${hairPath(s.look.hair, hairC)}
${s.look.faceHair >= 0 ? faceHairPath(s.look.faceHair, shade(hairC, 8)) : ''}
${brows}${eyes}${mouth}
<path d="M50 88 q${26 * buildW} 2 ${26 * buildW} 32" fill="none" stroke="${accent}" stroke-width="3.4" opacity="0.55"/>
${badge}
${injuryMark}${sickTint}
<path d="M64 34 q6 8 4 20" stroke="${accent}" stroke-width="1.6" fill="none" opacity="0.5" stroke-linecap="round"/>
</g>
${deadTint}
<rect x="0.5" y="0.5" width="99" height="119" fill="none" stroke="#2c3542" stroke-width="1"/>
</svg>`;
}
+525
View File
@@ -0,0 +1,525 @@
import type { WeatherId } from '../engine/types';
/* Canvas scene: layered parallax silhouettes, weather particles,
location props, camp buildings, and survivor figures.
Purely presentational — reads a small view-model each frame. */
export interface SceneChar {
name: string;
color: string;
selected?: boolean;
down?: boolean; // dead / gone
}
export interface SceneModel {
kind: 'camp' | string; // location template props key
label: string;
weather: WeatherId;
chars: SceneChar[];
buildings: Record<string, number>;
integrity: number;
dayTint?: number; // 0..1 progress through campaign (palette shift)
}
interface Palette { skyTop: string; skyBot: string; far: string; mid: string; near: string; ground: string; light: string }
const PALETTES: Record<string, Palette> = {
clear: { skyTop: '#2b3240', skyBot: '#8a6a4d', far: '#232a35', mid: '#1a202a', near: '#12161e', ground: '#0c0f15', light: '#e8b46a' },
rain: { skyTop: '#1c222c', skyBot: '#45505c', far: '#1a2029', mid: '#141922', near: '#0e1118', ground: '#090b10', light: '#7fa8bd' },
storm: { skyTop: '#10131c', skyBot: '#2c2733', far: '#151823', mid: '#0f1119', near: '#0a0c12', ground: '#07080c', light: '#9aa4ff' },
fog: { skyTop: '#39404a', skyBot: '#6a7280', far: '#2e343d', mid: '#23282f', near: '#181c22', ground: '#10131a', light: '#aab4bf' },
cold: { skyTop: '#20293a', skyBot: '#5d6f85', far: '#1d2531', mid: '#161d27', near: '#0f131b', ground: '#0a0d13', light: '#bcd2e8' },
heat: { skyTop: '#33261e', skyBot: '#a06b38', far: '#2a2018', mid: '#1e1712', near: '#140f0b', ground: '#0d0a07', light: '#f0a05a' },
};
interface Particle { x: number; y: number; vx: number; vy: number; l?: number; o: number; r?: number }
export class SceneView {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private model: SceneModel = { kind: 'camp', label: '', weather: 'clear', chars: [], buildings: {}, integrity: 100 };
private rain: Particle[] = [];
private motes: Particle[] = [];
private fogBlobs: Particle[] = [];
private mouse = { x: 0.5, y: 0.5 };
private t = 0;
private raf = 0;
private noise: HTMLCanvasElement | null = null;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.seedParticles();
this.makeNoise();
const loop = (ts: number) => { this.t = ts / 1000; this.render(); this.raf = requestAnimationFrame(loop); };
this.raf = requestAnimationFrame(loop);
}
destroy() { cancelAnimationFrame(this.raf); }
setMouse(nx: number, ny: number) { this.mouse.x = nx; this.mouse.y = ny; }
set(model: SceneModel) {
if (model.weather !== this.model.weather) this.seedParticles();
this.model = model;
}
private makeNoise() {
const c = document.createElement('canvas');
c.width = 160; c.height = 160;
const g = c.getContext('2d')!;
const img = g.createImageData(160, 160);
for (let i = 0; i < img.data.length; i += 4) {
const v = Math.floor(Math.random() * 255);
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
img.data[i + 3] = 10;
}
g.putImageData(img, 0, 0);
this.noise = c;
}
private seedParticles() {
const w = this.model?.weather ?? 'clear';
this.rain = []; this.motes = []; this.fogBlobs = [];
const rainN = w === 'storm' ? 220 : w === 'rain' ? 120 : 0;
for (let i = 0; i < rainN; i++) {
this.rain.push({ x: Math.random(), y: Math.random(), vx: -0.12 - Math.random() * 0.08, vy: w === 'storm' ? 1.4 + Math.random() : 0.75 + Math.random() * 0.4, l: 12 + Math.random() * 14, o: 0.18 + Math.random() * 0.3 });
}
const moteN = w === 'clear' ? 40 : w === 'heat' ? 60 : 26;
for (let i = 0; i < moteN; i++) {
this.motes.push({ x: Math.random(), y: Math.random(), vx: (Math.random() - 0.5) * 0.02, vy: -0.01 - Math.random() * 0.02, r: 0.6 + Math.random() * 1.4, o: 0.06 + Math.random() * 0.16 });
}
if (w === 'fog') {
for (let i = 0; i < 14; i++) {
this.fogBlobs.push({ x: Math.random(), y: 0.35 + Math.random() * 0.5, vx: 0.008 + Math.random() * 0.012, vy: 0, r: 80 + Math.random() * 160, o: 0.05 + Math.random() * 0.08 });
}
}
}
resize() {
const rect = this.canvas.parentElement!.getBoundingClientRect();
this.canvas.width = Math.floor(rect.width);
this.canvas.height = Math.floor(rect.height);
}
render() {
const { ctx } = this;
const W = this.canvas.width, H = this.canvas.height;
if (!W || !H) return;
const m = this.model;
const pal = PALETTES[m.weather] ?? PALETTES.clear;
const px = (this.mouse.x - 0.5), py = (this.mouse.y - 0.5);
const drift = (f: number) => ({ x: -px * 30 * f, y: -py * 14 * f });
// sky
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, pal.skyTop);
grad.addColorStop(0.72, pal.skyBot);
grad.addColorStop(1, pal.ground);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// sun glow
const gx = W * 0.68 + drift(0.3).x, gy = H * 0.3 + drift(0.3).y;
const rg = ctx.createRadialGradient(gx, gy, 0, gx, gy, H * 0.5);
rg.addColorStop(0, pal.light + '44');
rg.addColorStop(1, 'transparent');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
// far skyline
ctx.save();
ctx.translate(drift(0.5).x, drift(0.5).y);
ctx.fillStyle = pal.far;
this.skyline(W, H * 0.62, 0.62, 11, m.kind === 'forest' ? 'trees' : 'city');
ctx.restore();
// mid layer
ctx.save();
ctx.translate(drift(1).x, drift(1).y);
ctx.fillStyle = pal.mid;
this.midLayer(W, H, pal);
ctx.restore();
// ground
ctx.fillStyle = pal.ground;
ctx.fillRect(0, H * 0.78, W, H * 0.22);
// near layer: location props or camp
ctx.save();
ctx.translate(drift(1.5).x, drift(1.5).y);
if (m.kind === 'camp') this.drawCamp(W, H, pal);
else this.drawLocationProps(m.kind, W, H, pal);
ctx.restore();
// characters
this.drawChars(W, H);
// weather particles
this.drawWeather(W, H, pal);
// lightning flash on storm
if (m.weather === 'storm') {
const f = Math.max(0, Math.sin(this.t * 0.7) ** 24) * 0.5 + (Math.sin(this.t * 13.7) > 0.999 ? 0.35 : 0);
if (f > 0.02) { ctx.fillStyle = `rgba(200,210,255,${f})`; ctx.fillRect(0, 0, W, H); }
}
// fog blobs
for (const b of this.fogBlobs) {
b.x += b.vx / 100;
if (b.x > 1.3) b.x = -0.3;
const rr = b.r ?? 120;
const fg = ctx.createRadialGradient(b.x * W, b.y * H, 0, b.x * W, b.y * H, rr);
fg.addColorStop(0, `rgba(190,198,208,${b.o})`);
fg.addColorStop(1, 'transparent');
ctx.fillStyle = fg;
ctx.fillRect(b.x * W - rr, b.y * H - rr, rr * 2, rr * 2);
}
// vignette
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H * 0.95);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// grain
if (this.noise) {
ctx.globalAlpha = 0.5;
const ox = (this.t * 60) % 160, oy = (this.t * 37) % 160;
for (let x = -ox; x < W; x += 160) for (let y = -oy; y < H; y += 160) ctx.drawImage(this.noise, x, y);
ctx.globalAlpha = 1;
}
// scene label
if (m.label) {
ctx.font = '600 13px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = 'rgba(230,235,240,0.85)';
ctx.textAlign = 'left';
ctx.fillText(m.label.toUpperCase(), 18, 30);
ctx.strokeStyle = 'rgba(230,235,240,0.35)';
ctx.beginPath(); ctx.moveTo(18, 38); ctx.lineTo(18 + ctx.measureText(m.label.toUpperCase()).width, 38); ctx.stroke();
}
}
private skyline(W: number, hy: number, seedF: number, n: number, mode: 'city' | 'trees') {
const { ctx } = this;
let x = -40;
let s = Math.floor(seedF * 1000) + n;
const rnd = () => { s = (s * 16807) % 2147483647; return (s % 1000) / 1000; };
if (mode === 'trees') {
while (x < W + 40) {
const th = 40 + rnd() * 70, tw = 14 + rnd() * 18;
ctx.beginPath();
ctx.moveTo(x, hy + 20);
ctx.lineTo(x + tw / 2, hy + 20 - th);
ctx.lineTo(x + tw, hy + 20);
ctx.closePath(); ctx.fill();
x += tw * (0.5 + rnd());
}
} else {
while (x < W + 40) {
const bw = 30 + rnd() * 70, bh = 30 + rnd() * 110;
ctx.fillRect(x, hy + 20 - bh, bw, bh + 40);
// broken roofline
if (rnd() > 0.6) ctx.clearRect(x + bw * 0.55, hy + 20 - bh, bw * 0.2, bh * 0.3);
x += bw + 6 + rnd() * 26;
}
}
}
private midLayer(W: number, H: number, pal: Palette) {
const { ctx } = this;
const hy = H * 0.66;
// ruined fence line
ctx.strokeStyle = pal.near;
ctx.lineWidth = 4;
for (let x = -20; x < W + 20; x += 46) {
const h = 26 + ((x * 7919) % 17);
ctx.beginPath(); ctx.moveTo(x, hy + 60); ctx.lineTo(x + 6, hy + 60 - h); ctx.stroke();
}
ctx.beginPath(); ctx.moveTo(-10, hy + 34);
for (let x = -10; x < W + 10; x += 90) ctx.lineTo(x, hy + 34 + ((x * 13) % 7));
ctx.stroke();
// distant wrecked car silhouette
ctx.fillStyle = pal.near;
const cx = W * 0.78;
ctx.fillRect(cx, hy + 42, 74, 18);
ctx.fillRect(cx + 14, hy + 28, 36, 16);
ctx.beginPath(); ctx.arc(cx + 16, hy + 62, 8, 0, 7); ctx.arc(cx + 58, hy + 62, 8, 0, 7); ctx.fill();
}
private drawCamp(W: number, H: number, pal: Palette) {
const { ctx } = this;
const gy = H * 0.8;
const b = this.model.buildings;
// main shelter grows with level
const lvl = b.shelter ?? 0;
if (lvl > 0) {
const sw = 150 + lvl * 34, sh = 64 + lvl * 14;
ctx.fillStyle = pal.near;
ctx.fillRect(W * 0.16 - sw / 2, gy - sh, sw, sh);
ctx.beginPath();
ctx.moveTo(W * 0.16 - sw / 2 - 12, gy - sh);
ctx.lineTo(W * 0.16, gy - sh - 30 - lvl * 6);
ctx.lineTo(W * 0.16 + sw / 2 + 12, gy - sh);
ctx.closePath(); ctx.fill();
// windows glow
const lit = lvl >= 2 || (b.generator ?? 0) > 0;
ctx.fillStyle = lit ? pal.light : '#1c2129';
ctx.fillRect(W * 0.16 - 16, gy - sh * 0.62, 12, 14);
ctx.fillRect(W * 0.16 + 8, gy - sh * 0.62, 12, 14);
if (lit) {
const wg = ctx.createRadialGradient(W * 0.16, gy - sh * 0.55, 0, W * 0.16, gy - sh * 0.55, 60);
wg.addColorStop(0, pal.light + '33'); wg.addColorStop(1, 'transparent');
ctx.fillStyle = wg; ctx.fillRect(W * 0.16 - 60, gy - sh - 40, 120, 120);
}
}
// water collector
if ((b.water_collector ?? 0) > 0) {
const x = W * 0.34;
ctx.fillStyle = pal.near;
ctx.beginPath(); ctx.moveTo(x - 26, gy); ctx.lineTo(x, gy - 34); ctx.lineTo(x + 26, gy); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#4d6a7a'; ctx.lineWidth = 3;
ctx.strokeRect(x + 10, gy - 14, 14, 14);
}
// garden rows
if ((b.garden ?? 0) > 0) {
const gl = b.garden!;
for (let i = 0; i < 3 + gl; i++) {
const x = W * 0.47 + i * 13;
ctx.strokeStyle = '#4e6b41'; ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(x, gy); ctx.lineTo(x, gy - 10 - (i % 3) * 4); ctx.stroke();
ctx.fillStyle = '#5d7d4a';
ctx.beginPath(); ctx.ellipse(x, gy - 11 - (i % 3) * 4, 3.4, 2.2, 0, 0, 7); ctx.fill();
}
}
// watchtower
if ((b.watchtower ?? 0) > 0) {
const x = W * 0.63, th = 84 + (b.watchtower! * 18);
ctx.fillStyle = pal.near;
ctx.fillRect(x - 4, gy - th, 8, th);
ctx.fillRect(x - 18, gy - th - 16, 36, 18);
ctx.fillStyle = pal.light;
ctx.fillRect(x - 10, gy - th - 11, 8, 6);
}
// generator
if ((b.generator ?? 0) > 0) {
const x = W * 0.76;
ctx.fillStyle = pal.near;
ctx.fillRect(x - 16, gy - 22, 32, 22);
ctx.strokeStyle = '#556'; ctx.strokeRect(x - 16, gy - 22, 32, 22);
const flick = 0.5 + 0.5 * Math.sin(this.t * 7);
ctx.fillStyle = pal.light;
ctx.globalAlpha = 0.35 + flick * 0.4;
ctx.beginPath(); ctx.arc(x + 10, gy - 26, 3.4, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
}
// workshop & medical & storage as small sheds
const sheds: [number, number][] = [[0.86, b.workshop ?? 0], [0.26, b.medical ?? 0], [0.55, b.storage ?? 0]];
for (const [fx, l] of sheds) {
if (!l) continue;
const x = W * fx, sh = 30 + l * 8;
ctx.fillStyle = pal.near;
ctx.fillRect(x - 20, gy - sh, 40, sh);
ctx.beginPath(); ctx.moveTo(x - 24, gy - sh); ctx.lineTo(x, gy - sh - 14); ctx.lineTo(x + 24, gy - sh); ctx.closePath(); ctx.fill();
}
// integrity cracks
if (this.model.integrity < 55) {
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1.6;
for (let i = 0; i < 5; i++) {
const x = W * (0.2 + i * 0.13);
ctx.beginPath(); ctx.moveTo(x, gy - 40); ctx.lineTo(x + 7, gy - 24); ctx.lineTo(x + 2, gy - 8); ctx.stroke();
}
}
}
private drawLocationProps(kind: string, W: number, H: number, pal: Palette) {
const { ctx } = this;
const gy = H * 0.8;
ctx.fillStyle = pal.near;
switch (kind) {
case 'hospital': {
ctx.fillRect(W * 0.2, gy - 190, 180, 190);
ctx.fillRect(W * 0.52, gy - 130, 130, 130);
ctx.fillStyle = pal.light;
ctx.fillRect(W * 0.29, gy - 172, 22, 7); ctx.fillRect(W * 0.315, gy - 186, 7, 22);
for (let i = 0; i < 8; i++) ctx.fillStyle = (i % 3 === 0) ? pal.light + 'cc' : '#10151c', ctx.fillRect(W * 0.24 + (i % 4) * 38, gy - 150 + Math.floor(i / 4) * 46, 20, 26);
break;
}
case 'grocery': {
ctx.fillRect(W * 0.24, gy - 96, 250, 96);
ctx.fillStyle = pal.light;
ctx.fillRect(W * 0.3, gy - 70, 60, 34);
ctx.fillStyle = pal.near;
ctx.fillRect(W * 0.55, gy - 116, 60, 20);
break;
}
case 'police': {
ctx.fillRect(W * 0.28, gy - 110, 190, 110);
ctx.fillStyle = pal.light; ctx.fillRect(W * 0.42, gy - 84, 30, 40);
ctx.fillStyle = pal.near; ctx.fillRect(W * 0.6, gy - 150, 14, 44);
break;
}
case 'gas': {
ctx.fillRect(W * 0.3, gy - 84, 170, 84);
ctx.fillStyle = pal.light;
ctx.fillRect(W * 0.62, gy - 150, 10, 66);
ctx.beginPath(); ctx.arc(W * 0.625, gy - 154, 16, 0, 7); ctx.fill();
ctx.fillStyle = pal.near;
ctx.fillRect(W * 0.2, gy - 40, 60, 40);
break;
}
case 'warehouse': {
ctx.fillRect(W * 0.18, gy - 140, 300, 140);
ctx.strokeStyle = pal.mid; ctx.lineWidth = 3;
for (let i = 1; i < 8; i++) { ctx.beginPath(); ctx.moveTo(W * 0.18 + i * 37, gy - 140); ctx.lineTo(W * 0.18 + i * 37, gy); ctx.stroke(); }
break;
}
case 'school': {
ctx.fillRect(W * 0.22, gy - 104, 260, 104);
ctx.fillStyle = pal.light;
for (let i = 0; i < 6; i++) ctx.fillRect(W * 0.25 + i * 40, gy - 80, 22, 26);
ctx.fillStyle = pal.near; ctx.fillRect(W * 0.47, gy - 134, 12, 30);
break;
}
case 'farm': {
ctx.fillRect(W * 0.5, gy - 90, 150, 90);
ctx.beginPath(); ctx.moveTo(W * 0.48, gy - 90); ctx.lineTo(W * 0.575, gy - 128); ctx.lineTo(W * 0.67, gy - 90); ctx.closePath(); ctx.fill();
ctx.strokeStyle = pal.mid;
for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.moveTo(W * 0.16 + i * 26, gy - 6); ctx.lineTo(W * 0.2 + i * 26, gy - 26); ctx.stroke(); }
break;
}
case 'checkpoint': {
for (let i = 0; i < 9; i++) { ctx.fillRect(W * 0.3 + i * 26, gy - 26 - (i % 2) * 8, 24, 26 + (i % 2) * 8); }
ctx.fillRect(W * 0.62, gy - 60, 90, 60);
ctx.fillRect(W * 0.56, gy - 20, 50, 20);
break;
}
case 'forest': {
for (let i = 0; i < 12; i++) {
const x = W * 0.08 + i * W * 0.08 + ((i * 97) % 23);
const th = 90 + ((i * 53) % 70);
ctx.beginPath(); ctx.moveTo(x - 16, gy); ctx.lineTo(x, gy - th); ctx.lineTo(x + 16, gy); ctx.closePath(); ctx.fill();
}
break;
}
case 'highway': {
ctx.fillStyle = pal.mid;
ctx.fillRect(0, gy - 26, W, 30);
ctx.fillStyle = pal.light + '55';
for (let x = 0; x < W; x += 70) ctx.fillRect(x, gy - 14, 34, 4);
ctx.fillStyle = pal.near;
ctx.fillRect(W * 0.6, gy - 44, 80, 30); ctx.fillRect(W * 0.68, gy - 60, 40, 18);
ctx.fillRect(W * 0.24, gy - 38, 70, 26);
break;
}
case 'town': {
const hs = [120, 88, 140, 96];
for (let i = 0; i < 4; i++) ctx.fillRect(W * (0.14 + i * 0.19), gy - hs[i], 90, hs[i]);
ctx.fillStyle = pal.light;
ctx.fillRect(W * 0.21, gy - 90, 16, 20); ctx.fillRect(W * 0.73, gy - 70, 14, 16);
break;
}
case 'clinic':
default: {
ctx.fillRect(W * 0.3, gy - 92, 190, 92);
ctx.fillStyle = pal.light;
ctx.fillRect(W * 0.375, gy - 76, 16, 5); ctx.fillRect(W * 0.38, gy - 81, 5, 16);
break;
}
}
}
private drawChars(W: number, H: number) {
const { ctx } = this;
const chars = this.model.chars.filter(c => !c.down);
const gy = H * 0.86;
const n = chars.length;
chars.forEach((c, i) => {
const spread = Math.min(150, W * 0.09);
const x = W / 2 + (i - (n - 1) / 2) * spread;
const bob = Math.sin(this.t * 1.4 + i * 1.7) * 2;
const hgt = 92 + (i % 3) * 6;
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.ellipse(x, gy + 4, 20, 5, 0, 0, 7); ctx.fill();
// body
ctx.fillStyle = c.color;
ctx.beginPath();
ctx.moveTo(x - 13, gy);
ctx.quadraticCurveTo(x - 15, gy - hgt * 0.55, x - 9, gy - hgt * 0.72);
ctx.quadraticCurveTo(x, gy - hgt * 0.82, x + 9, gy - hgt * 0.72);
ctx.quadraticCurveTo(x + 15, gy - hgt * 0.55, x + 13, gy);
ctx.closePath(); ctx.fill();
// head
ctx.fillStyle = '#c9a184';
ctx.beginPath(); ctx.arc(x, gy - hgt * 0.82 - 9 + bob, 10, 0, 7); ctx.fill();
// scarf accent
ctx.strokeStyle = lighten(c.color, 30);
ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(x - 8, gy - hgt * 0.7); ctx.quadraticCurveTo(x, gy - hgt * 0.64, x + 8, gy - hgt * 0.7); ctx.stroke();
// selection ring
if (c.selected) {
ctx.strokeStyle = '#e6c069';
ctx.setLineDash([6, 5]);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(x, gy + 2, 26, 9, 0, 0, 7); ctx.stroke();
ctx.setLineDash([]);
}
// name
ctx.font = '600 11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(10,12,16,0.65)';
const tw = ctx.measureText(c.name).width;
ctx.fillRect(x - tw / 2 - 6, gy + 10, tw + 12, 17);
ctx.fillStyle = '#dfe6ee';
ctx.fillText(c.name, x, gy + 22);
});
}
private drawWeather(W: number, H: number, pal: Palette) {
const { ctx } = this;
if (this.rain.length) {
ctx.strokeStyle = 'rgba(173,196,215,0.5)';
ctx.lineWidth = 1.1;
ctx.beginPath();
for (const p of this.rain) {
p.x += p.vx / 120; p.y += p.vy / 60;
if (p.y > 1.05) { p.y = -0.05; p.x = Math.random(); }
const rx = p.x * W, ry = p.y * H;
ctx.moveTo(rx, ry);
ctx.lineTo(rx + (p.vx ?? 0) * 22, ry + (p.l ?? 12));
}
ctx.stroke();
}
for (const p of this.motes) {
p.x += p.vx / 100; p.y += p.vy / 100;
if (p.y < -0.05) { p.y = 1.05; p.x = Math.random(); }
if (p.x < -0.05 || p.x > 1.05) p.x = Math.random();
ctx.fillStyle = this.model.weather === 'cold' ? `rgba(225,235,245,${p.o})` : `rgba(216,200,170,${p.o})`;
ctx.beginPath(); ctx.arc(p.x * W, p.y * H, p.r ?? 1, 0, 7); ctx.fill();
}
// cold: frost edge
if (this.model.weather === 'cold') {
const fg = ctx.createRadialGradient(W / 2, H / 2, H * 0.42, W / 2, H / 2, H * 0.9);
fg.addColorStop(0, 'transparent');
fg.addColorStop(1, 'rgba(180,205,230,0.2)');
ctx.fillStyle = fg; ctx.fillRect(0, 0, W, H);
}
void pal;
}
}
function lighten(hex: string, amt: number): string {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, ((n >> 16) & 255) + amt);
const g = Math.min(255, ((n >> 8) & 255) + amt);
const b = Math.min(255, (n & 255) + amt);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
+1306
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "scripts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: true,
port: 5199,
// The game is served through a trycloudflare quick tunnel as well as
// locally; allow any Host header (no sensitive defaults to protect).
allowedHosts: true,
},
preview: {
host: true,
port: 5199,
allowedHosts: true,
},
});