commit 486201655b0b2630a805a75af61c927cec74ba61 Author: deepseek Date: Sun Aug 23 06:58:47 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3bc72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +shots/ +*.log +.vite/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..738de48 --- /dev/null +++ b/README.md @@ -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 | +|------|-------|--------------| +| 1–20 | Early Survival | Scarcity, first strangers, learning the valley | +| 21–50 | Expansion | New faces, bigger camp, the wide world opens | +| 51–80 | Escalation | Raiders, herds of the Dust-Sick, hard choices | +| 81–99 | 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.* diff --git a/index.html b/index.html new file mode 100644 index 0000000..a0a51c8 --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + + 100 Days After + + + +
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..abdfcaf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1598 @@ +{ + "name": "100-days-after", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "100-days-after", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^26.2.0", + "playwright-core": "^1.62.1", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0c9542 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/scripts/finale_test.mjs b/scripts/finale_test.mjs new file mode 100644 index 0000000..af295d3 --- /dev/null +++ b/scripts/finale_test.mjs @@ -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'); diff --git a/scripts/headless.ts b/scripts/headless.ts new file mode 100644 index 0000000..e43cf17 --- /dev/null +++ b/scripts/headless.ts @@ -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[number], rng: ReturnType): number | null { + const viable = ev.choices.map((c, i) => ({ c, i })).filter(x => reqMet(s, x.c.req)); + if (!viable.length) return null; + // heuristic: prefer later choices slightly (usually more interesting), random tiebreak + return rng.chance(0.6) ? viable[viable.length - 1].i : rng.pick(viable).i; +} + +function playSeed(seed: number, verbose = false): GameState { + const s = createNewGame(seed); + const ui = createRng(seed ^ 0xA11CE); // separate rng for bot decisions + let dayGuard = 0; + + while (s.phase !== 'gameover' && dayGuard++ < 130) { + // --- morning events --- + const events = collectDueEvents(s); + for (const ev of events) { + const cast = castFor(s, ev, ui); + const idx = pickChoice(s, ev, ui); + if (idx === null) continue; + try { + runEventChoice(s, ev, idx, cast); + } catch (e) { + fail(`seed ${seed} day ${s.day} event ${ev.id} choice ${idx}: ${e}`); + } + // resolve any encounters triggered inside events conservatively + while (s.pending.length) { + const enc = s.pending[0]; + const odds = estimateOdds(s, enc); + const approach = + odds.fight >= 55 ? 'fight' : + (odds.hide ?? 0) >= 50 ? 'hide' : + (odds.negotiate ?? 0) >= 45 ? 'negotiate' : 'fight'; + try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); } + } + } + + if (s.day >= 100 && s.flags.finale_path) { + finalizeEnding(s); + break; + } + + // --- plan actions --- + const alive = aliveOf(s); + if (!alive.length) break; + + const buildOrder = ['shelter', 'water_collector', 'storage', 'garden', 'medical', 'workshop', 'watchtower', 'generator']; + const actionsLeft = () => Math.min(s.ap, apMaxOf(s)); + + while (actionsLeft() > 0 && aliveOf(s).length > 0) { + const acted = (() => { + try { + // finish building priorities first + for (const b of buildOrder.slice(0, 4)) { + const { affordable } = canBuild(s, b); + if (affordable && s.ap > 0) { doBuild(s, b); return true; } + } + const gm = aliveOf(s); + const avgMorale = gm.reduce((t, x) => t + x.morale, 0) / Math.max(1, gm.length); + const known = s.locations.filter(l => l.discovered && !l.cleared); + + if (known.length < 3 || (ui.f() < 0.25 && s.day < 60)) { + const ex = gm[0]; + doExplore(s, ex.id); + return true; + } + + if (avgMorale < 32 && ui.f() < 0.5) { doRest(s); return true; } + if (s.camp.integrity < 45 && s.res.materials >= 2 && ui.f() < 0.5) { doRepair(s); return true; } + + // scavenge: prefer special-bearing sites sometimes, else richest known + const specialSite = known.filter(l => l.specialId && !l.specialFound) + .sort((a, b) => b.tier - a.tier)[0]; + const site = (specialSite && ui.f() < 0.5) ? specialSite + : known.sort((a, b) => b.lootPool - a.lootPool)[0]; + if (site && !(s.weather === 'storm' && site.tier >= 3)) { + const party = gm.slice(0, Math.min(2, gm.length)).map(x => x.id); + const r = doScavenge(s, site.uid, party); + if (r.ok) return true; + } + if (ui.f() < 0.35) { doHunt(s, gm.slice(0, 2).map(x => x.id)); return true; } + if (gm.length >= 2 && ui.f() < 0.3) { + doTalk(s, gm[0].id, gm[1].id); + return true; + } + // late-game: try travel for tier 3 loot + if (s.day > 30 && ui.f() < 0.3) { + const targets = s.locations.filter(l => l.tier === 3 && !l.cleared && l.discovered); + if (targets.length) { doTravel(s, targets[0].uid, gm.slice(0, 2).map(x => x.id)); return true; } + } + // remaining builds + for (const b of buildOrder.slice(4)) { + const { affordable } = canBuild(s, b); + if (affordable && s.ap > 0) { doBuild(s, b); return true; } + } + doRest(s); + return true; + } catch (e) { + fail(`seed ${seed} day ${s.day} action: ${e}`); + return false; + } + })(); + if (!acted) break; + // encounters mid-action + while (s.pending.length) { + const enc = s.pending[0]; + const odds = estimateOdds(s, enc); + const approach = (odds.hide ?? 0) >= 50 ? 'hide' : odds.fight >= 55 ? 'fight' : (odds.run ?? 0) >= 45 ? 'run' : (odds.negotiate ?? 0) >= 40 ? 'negotiate' : 'run'; + try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); } + } + // events fired by scavenging + void verbose; + } + + // resolve leftover pending + while (s.pending.length) { + const enc = s.pending[0]; + try { resolveEncounter(s, 'fight'); } catch (e) { fail(`pending encounter: ${e}`); } + } + + // --- night --- + try { + const rep = endDay(s); + if (rep.gameOver) { finalizeEnding(s); break; } + } catch (e) { + fail(`seed ${seed} endDay ${s.day}: ${e}`); + break; + } + + // sanity checks + for (const resKey of Object.keys(s.res) as (keyof typeof s.res)[]) { + if (!Number.isFinite(s.res[resKey])) fail(`NaN resource ${resKey} seed ${seed} day ${s.day}`); + } + for (const x of s.survivors) { + if (!Number.isFinite(x.hp) || !Number.isFinite(x.morale) || !Number.isFinite(x.hunger)) { + fail(`NaN vitals for ${x.name} seed ${seed} day ${s.day}`); + } + if ((x.morale < -0.001) || x.morale > 100.001) fail(`morale out of range ${x.name} ${x.morale}`); + } + if (aliveOf(s).length && s.day <= 100 && !anyPathPossible(s) && s.day > 95) { + // soft warning only + } + } + + finalizeEnding(s); + return s; +} + +function anyPathPossible(_s: GameState): boolean { return true; } + +/* ---------------- runner ---------------- */ + +function summarize(seed: number, s: GameState) { + const alive = aliveOf(s); + const dead = s.survivors.filter(x => !x.alive && !x.gone); + const left = s.survivors.filter(x => x.gone); + for (const d of dead) { + const key = (d.deathCause ?? '?').replace(/(fighting|by|the)\s+.*/i, m => m.slice(0, 28)); + deathCauses[key] = (deathCauses[key] ?? 0) + 1; + } + console.log( + `seed ${String(seed).padStart(8)} | end D${String(Math.min(s.day, 100)).padStart(3)} ` + + `| ${String(alive.length).padStart(2)} alive, ${dead.length} dead, ${left.length} left ` + + `| food ${String(Math.round(s.res.food)).padStart(3)} water ${String(Math.round(s.res.water)).padStart(3)} med ${String(Math.round(s.res.medicine)).padStart(2)} ` + + `| shelter L${s.camp.buildings.shelter ?? 0} gen L${s.camp.buildings.generator ?? 0} tower L${s.camp.buildings.watchtower ?? 0} ` + + `| radio ${(s.flags.radio_parts ?? 0)}/3 veh:${s.flags.vehicle ? 'Y' : 'n'} vault:${s.flags.seed_vault ? 'Y' : 'n'} ` + + `| ENDING: ${s.endingId}`, + ); +} + +const deathCauses: Record = {}; + +function hashState(s: GameState): string { + const { survivors, rel, res, flags, strFlags, camp, locations, rngCursor, seenEvents, stats, queuedEvents, endingId } = s; + return JSON.stringify({ + survivors: survivors.map(x => ({ i: x.id, hp: x.hp, hu: x.hunger, m: Math.round(x.morale), sk: x.skills, inj: x.injuries.map(j => [j.kind, j.severity, j.daysLeft]), dead: x.alive ? 0 : 1, gone: x.gone ? 1 : 0 })), + rel, res, flags, strFlags, camp, + locations: locations.map(l => ({ u: l.uid, r: l.risk, lp: l.lootPool, d: l.discovered ? 1 : 0, sp: l.specialFound ? 1 : 0 })), + rngCursor, seenEvents, stats, q: queuedEvents.length, endingId, + }); +} + +async function main() { + const argSeeds = process.argv.find(a => a.startsWith('--seeds=')); + const n = argSeeds ? parseInt(argSeeds.split('=')[1], 10) : 12; + console.log(`Running ${n} seeded campaigns…\n`); + + const endings: Record = {}; + let totalDead = 0, totalAliveEnd = 0, finished100 = 0; + + for (let i = 0; i < n; i++) { + const seed = (i * 2654435761) % 0xffffffff; + const s = playSeed(seed); + summarize(seed, s); + endings[s.endingId ?? 'none'] = (endings[s.endingId ?? 'none'] ?? 0) + 1; + totalDead += s.survivors.filter(x => !x.alive && !x.gone).length; + totalAliveEnd += aliveOf(s).length; + if (s.day >= 100 || s.endingId === 'silence') finished100++; + } + + // determinism check + console.log('\nDeterminism check…'); + const detSeed = 123456789; + const a = playSeed(detSeed); + const b = playSeed(detSeed); + if (hashState(a) !== hashState(b)) fail('determinism: same seed produced different states'); + else console.log('determinism OK'); + + console.log('\n==== SUMMARY ===='); + console.log('death causes:', Object.entries(deathCauses).sort((a, b) => b[1] - a[1])); + console.log('endings:', endings); + console.log(`avg dead/run: ${(totalDead / n).toFixed(2)}, avg survivors at end: ${(totalAliveEnd / n).toFixed(2)}, runs resolved: ${finished100}/${n}`); + if (failures === 0) console.log('\nALL CHECKS PASSED'); + else { console.log(`\n${failures} FAILURES`); process.exitCode = 1; } +} + +main(); diff --git a/scripts/shots.mjs b/scripts/shots.mjs new file mode 100644 index 0000000..18c88cd --- /dev/null +++ b/scripts/shots.mjs @@ -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'); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..54932a3 --- /dev/null +++ b/scripts/smoke.mjs @@ -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(); diff --git a/scripts/visual_check.mjs b/scripts/visual_check.mjs new file mode 100644 index 0000000..f41bc98 --- /dev/null +++ b/scripts/visual_check.mjs @@ -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;igetComputedStyle(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'); diff --git a/src/audio/audio.ts b/src/audio/audio.ts new file mode 100644 index 0000000..4a15245 --- /dev/null +++ b/src/audio/audio.ts @@ -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 = { + // 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(); diff --git a/src/content/buildings.ts b/src/content/buildings.ts new file mode 100644 index 0000000..c02d53c --- /dev/null +++ b/src/content/buildings.ts @@ -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> { + const def = buildingById(id); + const raw = def.levels[targetLevel - 1]?.cost ?? {}; + return { ...raw }; +} diff --git a/src/content/enemies.ts b/src/content/enemies.ts new file mode 100644 index 0000000..000413b --- /dev/null +++ b/src/content/enemies.ts @@ -0,0 +1,40 @@ +import type { EnemyDef } from '../engine/types'; + +/* Enemy archetypes. Strength scales with day via engine.enemyScale(). */ + +export const ENEMIES: Record = { + 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; +} diff --git a/src/content/events/index.ts b/src/content/events/index.ts new file mode 100644 index 0000000..9cc40c1 --- /dev/null +++ b/src/content/events/index.ts @@ -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(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; +} diff --git a/src/content/events/people.ts b/src/content/events/people.ts new file mode 100644 index 0000000..131e2c6 --- /dev/null +++ b/src/content/events/people.ts @@ -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; +} diff --git a/src/content/events/story.ts b/src/content/events/story.ts new file mode 100644 index 0000000..108891f --- /dev/null +++ b/src/content/events/story.ts @@ -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 = { + 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.`, + }, +}; diff --git a/src/content/events/world.ts b/src/content/events/world.ts new file mode 100644 index 0000000..7d60724 --- /dev/null +++ b/src/content/events/world.ts @@ -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]); +} diff --git a/src/content/locations.ts b/src/content/locations.ts new file mode 100644 index 0000000..24b113e --- /dev/null +++ b/src/content/locations.ts @@ -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)!; +} diff --git a/src/content/survivors.ts b/src/content/survivors.ts new file mode 100644 index 0000000..b16e2b1 --- /dev/null +++ b/src/content/survivors.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = {}; + 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(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); +} diff --git a/src/content/world.ts b/src/content/world.ts new file mode 100644 index 0000000..b0036ee --- /dev/null +++ b/src/content/world.ts @@ -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 = { + 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 = { + 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”.', +]; diff --git a/src/engine/ending.ts b/src/engine/ending.ts new file mode 100644 index 0000000..d9e85bd --- /dev/null +++ b/src/engine/ending.ts @@ -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); +} diff --git a/src/engine/eventctx.ts b/src/engine/eventctx.ts new file mode 100644 index 0000000..33b4439 --- /dev/null +++ b/src/engine/eventctx.ts @@ -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> { + // Each entry is a probabilistic find; mult shifts both chance and plenty. + const out: Partial> = {}; + 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; +} diff --git a/src/engine/rng.ts b/src/engine/rng.ts new file mode 100644 index 0000000..8dd4252 --- /dev/null +++ b/src/engine/rng.ts @@ -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); +} diff --git a/src/engine/save.ts b/src/engine/save.ts new file mode 100644 index 0000000..0e898a7 --- /dev/null +++ b/src/engine/save.ts @@ -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); +} diff --git a/src/engine/sim-core.ts b/src/engine/sim-core.ts new file mode 100644 index 0000000..51b69ba --- /dev/null +++ b/src/engine/sim-core.ts @@ -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 { + 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; +} diff --git a/src/engine/sim.ts b/src/engine/sim.ts new file mode 100644 index 0000000..69690ac --- /dev/null +++ b/src/engine/sim.ts @@ -0,0 +1,1130 @@ +import type { + Encounter, EventDef, GameState, LocationInst, LogEntry, ResourceId, + Rng, Survivor, +} from './types'; +import { createRng } from './rng'; +import { STORY_BEATS } from '../content/events/story'; +import { getEvent, rollEvent, ALL_EVENTS, eligible } from '../content/events'; +import { makeCtx, killAndGrief, queueEncounter, rollLoot, type CtxSession } from './eventctx'; +import { + A, aliveOf, addRel, applyMorale, clampRes, defenseRating, + effSkill, groupMorale, hasTrait, moraleFloor, relKey, +} from './sim-core'; +import { WEATHER, enemyScale, phaseOf } from '../content/world'; +import { templateById } from '../content/locations'; +import { enemyById } from '../content/enemies'; +import { buildingById } from '../content/buildings'; + +/* ============================================================ + RNG plumbing + ============================================================ */ + +export function withRng(s: GameState, fn: (rng: Rng) => T): T { + const rng = createRng(s.seed, s.rngCursor); + const out = fn(rng); + s.rngCursor = rng.cursor(); + return out; +} + +/* ============================================================ + AP & basic queries + ============================================================ */ + +export function apMaxOf(s: GameState): number { + let ap = 3; + if ((s.camp.buildings.shelter ?? 0) >= 2) ap++; + if (aliveOf(s).length >= 6) ap++; + return Math.min(5, ap); +} + +export function spendAp(s: GameState): boolean { + if (s.ap <= 0) return false; + s.ap--; + return true; +} + +/* ============================================================ + Actions + ============================================================ */ + +export interface ActionResult { + ok: boolean; + lines: LogEntry[]; + firedEvent?: EventDef | null; + encounter?: Encounter | null; +} + +function result(lines: LogEntry[], extra: Partial = {}): ActionResult { + return { ok: true, lines, ...extra }; +} + +const sayTo = (s: GameState, session: CtxSession) => (text: string, tone: LogEntry['kind'] = 'info') => { + session.lines.push({ day: s.day, text, kind: tone }); + s.log.push({ day: s.day, text, kind: tone }); +}; + +/* ---------------- SCAVENGE ---------------- */ + +export function doScavenge(s: GameState, locUid: string, partyIds: string[]): ActionResult { + const loc = s.locations.find(l => l.uid === locUid); + const party = partyIds.map(id => s.survivors.find(x => x.id === id)).filter((x): x is Survivor => !!x && A(x)); + if (!loc || !loc.discovered || !party.length) return { ok: false, lines: [] }; + if (s.weather === 'storm' && loc.tier >= 3) { + return result([{ day: s.day, kind: 'bad', text: 'Nobody can reach that far in this storm. Wait it out.' }], { ok: false }); + } + if (!spendAp(s)) return { ok: false, lines: [] }; + s.stats.scavengedRuns++; + + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const session: CtxSession = { lines }; + const say = sayTo(s, session); + const tmpl = templateById(loc.templateId); + const w = WEATHER[s.weather]; + + // ambush? + let ambushP = loc.risk / 240 + w.ambush; + if ((s.camp.buildings.watchtower ?? 0) >= 2) ambushP -= 0.05; + ambushP *= (s.weather === 'fog' ? 1.4 : 1); + if (rng.chance(ambushP)) { + const enemy = tmpl.enemyKind !== 'none' ? enemyById(tmpl.enemyKind) : enemyById('looter'); + queueEncounter(s, enemy, party.map(p => p.id), + `Shapes detach from the cover of ${loc.name.toLowerCase()} — ${enemy.name.toLowerCase()}, waiting for exactly this.`, + 'scavenge', enemyScale(s.day)); + say(`${enemy.name} have the approach watched. It comes down to steel and nerve.`, 'combat'); + return result(lines, { encounter: s.pending[s.pending.length - 1] }); + } + + // loot + const avgScav = party.reduce((t, p) => t + effSkill(p, 'scavenge'), 0) / party.length; + const moraleBonus = groupMorale(s) > 70 ? 1.1 : 1; + const mult = tmpl.richness * w.scavenge * (1 + avgScav * 0.07) * moraleBonus * + (loc.lootPool > 900 ? 0.5 : Math.max(0.15, loc.lootPool / Math.max(1, tmpl.lootUnits))); + const loot = rollLoot(rng, tmpl.loot, mult); + const parts: string[] = []; + for (const k of Object.keys(loot) as ResourceId[]) { + const v = loot[k]!; + s.res[k] += v; + parts.push(`+${v} ${k}`); + } + clampRes(s); + + if (loc.lootPool <= 900) loc.lootPool = Math.max(0, loc.lootPool - 2); + loc.searches++; + + const names = party.map(p => p.name).join(' and '); + if (parts.length) say(`${names} work through ${tmpl.name.toLowerCase()}. Hauled back: ${parts.join(', ')}.`, 'good'); + else say(`${names} pick through the remains of ${tmpl.name.toLowerCase()}. Rust, dust, and disappointment.`); + + // xp + for (const p of party) if (rng.chance(0.4) && p.skills.scavenge < 10) p.skills.scavenge++; + + // special discovery + if (!loc.specialFound && loc.specialId) { + const threshold = loc.lootPool > 900 ? 1 : 0.55; + if (rng.chance(threshold * 0.75) || loc.searches >= 3) grantSpecial(s, loc, say, rng); + } + + // depletion + if (loc.lootPool <= 0 && loc.lootPool > 900) { /* never */ } + if (loc.lootPool === 0) { + loc.cleared = true; + say(`${loc.name} is picked clean now. Nothing left but ghosts and paperwork.`); + } + + // location-flavored event? + if (rng.chance(0.17)) { + const candidates = ALL_EVENTS.filter(e => + eligiblePublic(s, e) && + e.tag === tmpl.eventTags[Math.floor(rng.f() * tmpl.eventTags.length)]); + if (candidates.length) { + const ev = rng.pick(candidates); + return result(lines, { firedEvent: ev }); + } + } + + return result(lines); + }); +} + +function firstAlive(s: GameState): Survivor { + return aliveOf(s)[0] ?? DUMMY; +} +const DUMMY: Survivor = { + id: 'dummy', name: '?', age: 0, occ: '', occLabel: '', skills: {} as Survivor['skills'], + traitIds: [], personality: 'loner', personalityLabel: '', goalId: 'keepAlive', goalText: '', + hp: 100, hunger: 80, morale: 50, injuries: [], sick: false, weapon: null, + memories: [], portraitSeed: 0, look: {} as Survivor['look'], alive: true, joinedDay: 1, +}; + +function grantSpecial(s: GameState, loc: LocationInst, say: ReturnType, rng: Rng) { + const tmpl = templateById(loc.templateId); + const sp = tmpl.specials.find(x => x.id === loc.specialId); + loc.specialFound = true; + switch (loc.specialId) { + case 'radio_part': + s.flags.radio_parts = (s.flags.radio_parts ?? 0) + 1; + say(`DISCOVERY — ${sp?.name}: ${sp?.text} (${s.flags.radio_parts}/3 radio parts recovered.)`, 'story'); + applyMorale(s, firstAlive(s), 4); + break; + case 'vehicle': + s.strFlags.vehicle = '1'; + s.flags.vehicle = 1; + say(`DISCOVERY — ${sp?.name}: ${sp?.text}`, 'story'); + break; + case 'city_map': + for (let i = 0; i < 2; i++) { + const hidden = s.locations.filter(l => !l.discovered); + if (hidden.length) rng.pick(hidden).discovered = true; + } + say(`DISCOVERY — ${sp?.name}: ${sp?.text} Two marked caches added to your map.`, 'story'); + break; + case 'seed_vault': + s.flags.seed_vault = 1; + say(`DISCOVERY — ${sp?.name}: ${sp?.text}`, 'story'); + applyMorale(s, firstAlive(s), 6); + s.flags.hope = (s.flags.hope ?? 0) + 1; + break; + } + s.log.push({ day: s.day, text: `Special discovery at ${loc.name}: ${sp?.name}`, kind: 'story' }); +} + +/* ---------------- HUNT ---------------- */ + +export function doHunt(s: GameState, partyIds: string[]): ActionResult { + const party = partyIds.map(id => s.survivors.find(x => x.id === id)).filter((x): x is Survivor => !!x && A(x)); + if (!party.length) return { ok: false, lines: [] }; + if (!spendAp(s)) return { ok: false, lines: [] }; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + const hunter = party.reduce((b, p) => (effSkill(p, 'hunt') > effSkill(b, 'hunt') ? p : b), party[0]); + const skillSum = party.reduce((t, p) => t + effSkill(p, 'hunt'), 0); + const w = WEATHER[s.weather]; + let food = Math.max(0, Math.round(skillSum * (0.8 + rng.f() * 0.5) * w.scavenge)); + if (hasTrait(hunter, 'sharpEye')) food += 1; + + if (food <= 0) { + say(`${hunter.name} tracks all afternoon and finds prints, droppings, and humbling silence. The woods are empty today.`); + } else { + s.res.food += food; + clampRes(s); + say(`${hunter.name} reads the gray treeline like a ledger and comes back with ${food} food. The camp eats well tonight${w.id !== 'clear' ? ', despite the weather' : ''}.`, 'good'); + } + for (const p of party) if (rng.chance(0.4) && p.skills.hunt < 10) p.skills.hunt++; + + // danger + const dangerP = 0.13 + w.ambush; + if (rng.chance(dangerP)) { + if (rng.chance(0.5)) { + queueEncounter(s, enemyById('dogpack'), party.map(p => p.id), + 'The pack was hunting the same herd — and now there are two predators and one deer\'s worth of meat.', 'hunt', enemyScale(s.day) * 0.8); + return result(lines, { encounter: s.pending[s.pending.length - 1] }); + } else { + const victim = rng.pick(party); + const inj = rng.pick([ + ['sprain', 'Twisted ankle in a burrow', 1, 4], + ['cut', 'Slashed by a boar\'s tusk', 2, 6], + ['fracture', 'Fell from a deer stand', 3, 10], + ] as const); + victim.injuries.push({ kind: inj[0] as never, label: inj[1], severity: inj[2], daysLeft: inj[3] }); + victim.hp = Math.max(5, victim.hp - inj[2] * 6); + say(`${victim.name}: ${inj[1]}.`, 'bad'); + } + } + return result(lines); + }); +} + +/* ---------------- EXPLORE ---------------- */ + +function tierUnlocked(s: GameState, tier: 1 | 2 | 3): boolean { + if (tier <= 2) return true; + return s.day >= 12 || !!s.flags.vehicle; +} + +export function doExplore(s: GameState, explorerId: string): ActionResult { + const explorer = s.survivors.find(x => x.id === explorerId && A(x)); + if (!explorer) return { ok: false, lines: [] }; + const candidates = s.locations.filter(l => !l.discovered && tierUnlocked(s, l.tier)); + if (!candidates.length) { + return result([{ day: s.day, kind: 'info', text: 'There is nothing left nearby to find. The map has no blank spaces left.' }], { ok: false }); + } + if (!spendAp(s)) return { ok: false, lines: [] }; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + // prefer nearest tier + const tierOrder = [1, 2, 3] as const; + let found: LocationInst | null = null; + for (const t of tierOrder) { + const pool = candidates.filter(c => c.tier === t); + if (pool.length) { found = rng.pick(pool); break; } + } + const loc = found!; + loc.discovered = true; + + const w = WEATHER[s.weather]; + say(`${explorer.name} walks the gray miles until the world offers something new: ${loc.name}. “${templateById(loc.templateId).short}”`, 'good'); + if (rng.chance(0.35) && effSkill(explorer, 'scavenge') < 10) explorer.skills.scavenge++; + + if (rng.chance(0.14 + loc.risk / 500 + w.ambush)) { + const tmpl = templateById(loc.templateId); + const enemy = tmpl.enemyKind !== 'none' ? enemyById(tmpl.enemyKind) : enemyById('looter'); + queueEncounter(s, enemy, [explorer.id], `The first thing ${explorer.name} learns about ${loc.name} is that it isn't empty.`, 'explore', enemyScale(s.day)); + return result(lines, { encounter: s.pending[s.pending.length - 1] }); + } + return result(lines); + }); +} + +/* ---------------- REST / TALK / REPAIR ---------------- */ + +export function doRest(s: GameState): ActionResult { + if (!spendAp(s)) return { ok: false, lines: [] }; + s.flags.restTonight = 1; + return result([{ day: s.day, kind: 'good', text: 'The camp stands down early. Fires low, watches set, everyone told to actually sleep.' }]); +} + +export function doTalk(s: GameState, targetId: string, otherId?: string): ActionResult { + const target = s.survivors.find(x => x.id === targetId && A(x)); + if (!target) return { ok: false, lines: [] }; + if (!spendAp(s)) return { ok: false, lines: [] }; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + + if (otherId) { + const other = s.survivors.find(x => x.id === otherId && A(x)); + if (!other) return result(lines, { ok: false }); + const speaker = bestCharisma(s, target.id, other.id); + const delta = 5 + Math.floor(effSkill(speaker, 'charisma') / 2); + addRel(s, target.id, other.id, delta); + applyMorale(s, target, 3); applyMorale(s, other, 3); + say(`You seat ${target.name} and ${other.name} together with a job to do and let conversation happen. Something loosens between them (+${delta}).`, 'good'); + if (rng.chance(0.25) && effSkill(speaker, 'charisma') < 10) speaker.skills.charisma++; + } else { + const speaker = bestCharisma(s, target.id); + const delta = 6 + effSkill(speaker, 'charisma'); + applyMorale(s, target, delta); + say(`Someone sits with ${target.name} through the worst of the evening and listens until the words run out (+${delta} morale).`, 'good'); + if (target.memories.length < 24) target.memories.push({ day: s.day, text: 'Somebody really listened, for once.', tone: 'good' }); + if (rng.chance(0.3) && speaker.skills.charisma < 10) speaker.skills.charisma++; + } + return result(lines); + }); +} + +function bestCharisma(s: GameState, excludeId: string, exclude2?: string): Survivor { + const pool = aliveOf(s).filter(x => x.id !== excludeId && x.id !== exclude2); + return pool.reduce((b, x) => (effSkill(x, 'charisma') > effSkill(b, 'charisma') ? x : b), pool[0]); +} + +export function doRepair(s: GameState): ActionResult { + if (s.res.materials < 2) { + return result([{ day: s.day, kind: 'bad', text: 'Repair needs 2 materials. The pile is bare.' }], { ok: false }); + } + if (!spendAp(s)) return { ok: false, lines: [] }; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + s.res.materials -= 2; + const eng = aliveOf(s).reduce((b, x) => (effSkill(x, 'engineering') > effSkill(b, 'engineering') ? x : b), aliveOf(s)[0]); + const amt = 12 + effSkill(eng, 'engineering') * 2 + ((s.camp.buildings.workshop ?? 0) >= 1 ? 4 : 0); + s.camp.integrity = Math.min(100, s.camp.integrity + amt); + say(`${eng.name} spends the day with hammer and scavenged lumber. Camp integrity +${amt}.`, 'good'); + if (rng.chance(0.35) && eng.skills.engineering < 10) eng.skills.engineering++; + return result(lines); + }); +} + +/* ---------------- BUILD ---------------- */ + +export function buildCostFor(s: GameState, id: string, targetLevel: number): Partial> { + const raw = buildingById(id).levels[targetLevel - 1]?.cost ?? {}; + const disc = (s.camp.buildings.workshop ?? 0) >= 2 ? 0.85 : 1; + const out: Partial> = {}; + for (const k of Object.keys(raw) as ResourceId[]) { + out[k] = Math.max(1, Math.floor((raw[k] ?? 0) * disc)); + } + return out; +} + +export function canBuild(s: GameState, id: string): { affordable: boolean; nextLevel: number; cost: Partial> } { + const def = buildingById(id); + const cur = s.camp.buildings[id] ?? 0; + const nextLevel = cur + 1; + const cost = buildCostFor(s, id, nextLevel); + let affordable = nextLevel <= def.maxLevel; + for (const k of Object.keys(cost) as ResourceId[]) { + if (s.res[k] < (cost[k] ?? 0)) affordable = false; + } + return { affordable, nextLevel, cost }; +} + +export function doBuild(s: GameState, id: string): ActionResult { + const { affordable, nextLevel, cost } = canBuild(s, id); + if (!affordable) return { ok: false, lines: [] }; + if (!spendAp(s)) return { ok: false, lines: [] }; + const def = buildingById(id); + for (const k of Object.keys(cost) as ResourceId[]) s.res[k] -= cost[k] ?? 0; + s.camp.buildings[id] = nextLevel; + s.flags[`bldg_${id}`] = nextLevel; + s.camp.integrity = Math.min(100, s.camp.integrity + 4); + s.log.push({ day: s.day, text: `${def.name} raised to level ${nextLevel}.`, kind: 'good' }); + return withRng(s, () => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + for (const x of aliveOf(s)) applyMorale(s, x, 3); + say(`${def.name} — level ${nextLevel}. ${def.levels[nextLevel - 1].desc}`, 'good'); + return result(lines); + }); +} + +/* ---------------- TRAVEL ---------------- */ + +export function travelTargets(s: GameState): LocationInst[] { + return s.locations.filter(l => l.tier === 3 && !l.cleared && (l.discovered || s.day >= 12 || !!s.flags.vehicle)); +} + +export function doTravel(s: GameState, locUid: string, partyIds: string[]): ActionResult { + const loc = s.locations.find(l => l.uid === locUid); + const party = partyIds.map(id => s.survivors.find(x => x.id === id)).filter((x): x is Survivor => !!x && A(x)); + if (!loc || !party.length) return { ok: false, lines: [] }; + if (s.ap < 2) return result([{ day: s.day, kind: 'bad', text: 'Travel takes the whole day (2 actions).' }], { ok: false }); + const fuelNeed = s.flags.vehicle ? 1 : 0; + if (fuelNeed && s.res.fuel < fuelNeed) { + return result([{ day: s.day, kind: 'bad', text: 'The vehicle is dry. Travel needs fuel.' }], { ok: false }); + } + s.ap -= 2; + if (fuelNeed) s.res.fuel -= fuelNeed; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = sayTo(s, { lines }); + const w = WEATHER[s.weather]; + loc.discovered = true; + const tmpl = templateById(loc.templateId); + + say(`${party.map(p => p.name).join(' and ')} push out to ${loc.name}${fuelNeed ? ' with the engine droning like a promise' : ' on foot, watching every shadow'}${w.travel > 1.1 ? `. The ${w.label.toLowerCase()} makes every mile argumentative` : ''}.`); + + // road toll + if (!fuelNeed) { + const tired = rng.pick(party); + tired.hp = Math.max(10, tired.hp - 8); + say(`${tired.name} pays the road tax in blisters and exhaustion.`); + } + + // ambush on the road + let ambP = 0.22 * w.travel + w.ambush; + if ((s.camp.buildings.watchtower ?? 0) >= 2) ambP -= 0.04; + if (rng.chance(ambP)) { + const enemy = tmpl.enemyKind !== 'none' ? enemyById(tmpl.enemyKind) : enemyById('looter'); + queueEncounter(s, enemy, party.map(p => p.id), `${enemy.name} hold the route ahead. There is no clean way around.`, 'travel', enemyScale(s.day) * 1.1); + return result(lines, { encounter: s.pending[s.pending.length - 1] }); + } + + // big haul + const avgScav = party.reduce((t, p) => t + effSkill(p, 'scavenge'), 0) / party.length; + const mult = tmpl.richness * w.scavenge * (1.4 + avgScav * 0.06) * Math.max(0.2, loc.lootPool / Math.max(1, tmpl.lootUnits || 1)); + const loot = rollLoot(rng, tmpl.loot, mult); + const parts: string[] = []; + for (const k of Object.keys(loot) as ResourceId[]) { + s.res[k] += loot[k]!; + parts.push(`+${loot[k]} ${k}`); + } + clampRes(s); + if (loc.lootPool <= 900) loc.lootPool = Math.max(0, loc.lootPool - 3); + if (parts.length) say(`They return loaded: ${parts.join(', ')}.`, 'good'); + + if (!loc.specialFound && loc.specialId && rng.chance(0.7)) grantSpecial(s, loc, say, rng); + if (loc.lootPool === 0) { loc.cleared = true; say(`${loc.name} gives everything it had. It's done.`); } + + for (const p of party) if (rng.chance(0.4) && p.skills.scavenge < 10) p.skills.scavenge++; + return result(lines); + }); +} + +/* ============================================================ + ENCOUNTERS (tactical) + ============================================================ */ + +export interface Odds { fight: number; hide: number | null; run: number | null; negotiate: number | null; tribute: Partial> } + +export function estimateOdds(s: GameState, enc: Encounter): Odds { + const party = enc.party.map(id => s.survivors.find(x => x.id === id)!).filter(A); + const w = WEATHER[s.weather]; + let power = 0; + let ammoWeapons = 0; + for (const p of party) { + let base = 8 + effSkill(p, 'combat') * 7; + const wpn = p.weapon; + if (wpn) { + base += wpnPower(wpn); + if (wpnUsesAmmo(wpn)) ammoWeapons++; + } + base *= 0.75 + p.morale / 200; + base *= 0.65 + p.hp / 280; + if (hasTrait(p, 'brave')) base *= 1.08; + if (hasTrait(p, 'hotheaded')) base *= 1.05; + if (hasTrait(p, 'cowardly')) base *= 0.9; + power += base; + } + if (s.res.ammo <= 0 && ammoWeapons > 0) power *= 0.78; // dry guns + power *= w.combat; + + const fight = clamp01(power / (power + enc.strength * 1.05)); + + const avgScav = party.reduce((t, p) => t + effSkill(p, 'scavenge'), 0) / Math.max(1, party.length); + let hide = 0.32 + avgScav * 0.05 - enc.perception / 320 - party.length * 0.035; + if (party.some(p => hasTrait(p, 'quiet'))) hide += 0.08; + if (s.weather === 'fog' || s.weather === 'storm') hide += 0.1; + hide = clamp01(hide); + + let run = 0.42 + (party.reduce((t, p) => t + p.hp, 0) / Math.max(1, party.length)) / 260 - enc.speed / 260; + if (WEATHER[s.weather].travel > 1.2) run -= 0.1; + run = clamp01(run); + + let negP: number | null = null; + const tribute: Partial> = {}; + if (enc.greed > 0) { + const avgCha = party.reduce((t, p) => t + effSkill(p, 'charisma'), 0) / Math.max(1, party.length); + negP = clamp01(0.28 + avgCha * 0.07 - enc.strength / 420 + (party.some(p => hasTrait(p, 'calm')) ? 0.08 : 0)); + const foodGive = Math.min(Math.ceil(enc.greed * 0.7), s.res.food); + const matGive = Math.min(Math.ceil(enc.greed * 0.5), s.res.materials); + if (foodGive > 0) tribute.food = foodGive; + if (matGive > 0) tribute.materials = matGive; + if (foodGive + matGive === 0) negP = null; + } + + // all values are percentages for UI display + return { fight: roundPct(fight), hide: roundPct(hide), run: roundPct(run), negotiate: negP === null ? null : roundPct(negP), tribute }; +} + +/** Internal win probability for auto-resolved fights (fraction 0..1). */ +export function fightWinChance(s: GameState, enc: Encounter): number { + const party = enc.party.map(id => s.survivors.find(x => x.id === id)!).filter(A); + const w = WEATHER[s.weather]; + let power = 0; + for (const p of party) { + let base = 8 + effSkill(p, 'combat') * 7 + (p.weapon ? wpnPower(p.weapon) : 0); + base *= 0.75 + p.morale / 200; + base *= 0.65 + p.hp / 280; + if (hasTrait(p, 'brave')) base *= 1.08; + if (hasTrait(p, 'hotheaded')) base *= 1.05; + if (hasTrait(p, 'cowardly')) base *= 0.9; + power += base; + } + if (!s.res.ammo && party.some(p => p.weapon && wpnUsesAmmo(p.weapon))) power *= 0.78; + power *= w.combat; + return clamp01(power / (power + enc.strength * 1.05)); +} + +function roundPct(v: number): number { return Math.round(clamp01(v) * 100); } +function clamp01(v: number): number { return Math.max(0.02, Math.min(0.97, v)); } +function wpnPower(id: string): number { return ({ crowbar: 4, machete: 7, pistol: 11, rifle: 15, shotgun: 14, bat: 5 } as Record)[id] ?? 0; } +function wpnUsesAmmo(id: string): boolean { return ['pistol', 'rifle', 'shotgun'].includes(id); } + +export type Approach = 'fight' | 'hide' | 'run' | 'negotiate'; + +export function resolveEncounter(s: GameState, approach: Approach): { lines: LogEntry[] } { + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const session: CtxSession = { lines }; + const say = sayTo(s, session); + const enc = s.pending.shift(); + if (!enc) return { lines }; + const party = enc.party.map(id => s.survivors.find(x => x.id === id)!).filter(A); + const odds = estimateOdds(s, enc); // percentage values for hide/run/negotiate + const finaleHold = !!s.flags.finale_path && s.flags.finale_path === 3; + + const finishWin = () => { + s.stats.kills++; + if (enc.context === 'camp') { + s.flags.won_camp_defense = 1; + if (/red hand|raider|last raid/i.test(enc.enemyName)) { + s.flags.raiders_broken = 1; + say('The Red Hand breaks and runs. Some of them keep running all the way out of history.', 'combat'); + } + } + if (finaleHold) { + s.flags.hold_won = 1; + s.flags.raiders_broken = 1; + } + if (enc.lootOnWin.length) { + const loot = rollLoot(rng, enc.lootOnWin, 1.2); + const parts: string[] = []; + for (const k of Object.keys(loot) as ResourceId[]) { + s.res[k] += loot[k]!; + parts.push(`+${loot[k]} ${k}`); + } + clampRes(s); + if (parts.length) say(`Salvaged from the aftermath: ${parts.join(', ')}.`, 'good'); + } + applyMorale(s, party[0] ?? firstAlive(s), 6); + for (const p of party) { + if (rng.chance(0.5) && p.skills.combat < 10) p.skills.combat++; + p.memories.push({ day: s.day, text: `Survived the fight with ${enc.enemyName}.`, tone: 'neutral' }); + } + }; + + const casualty = (severityBias: number) => { + if (!party.length) return; + const sorted = [...party].sort((a, b) => (a.hp - a.morale * 0.2) - (b.hp - b.morale * 0.2)); + const unlucky = rng.pick(sorted.slice(0, Math.max(1, Math.ceil(sorted.length / 2)))); + const deathP = 0.02 + severityBias * 0.1 + (hasTrait(unlucky, 'fragile') ? 0.04 : 0); + if (rng.chance(deathP)) { + killAndGrief(s, unlucky, `killed fighting ${enc.enemyName}`, session); + if (finaleHold) s.strFlags.hold_fallen = unlucky.name; + } else { + const sev = severityBias > 0.4 ? 3 : rng.i(1, 2); + const kinds = ['cut', 'sprain', 'fracture', 'burn'] as const; + const labels = ['Slash wound', 'Wrenched joint', 'Cracked ribs', 'Burn tract']; + const idx = rng.i(0, 3); + unlucky.injuries.push({ kind: kinds[idx], label: `${labels[idx]} from the fight`, severity: sev, daysLeft: sev * 3 }); + unlucky.hp = Math.max(4, unlucky.hp - sev * 7); + say(`${unlucky.name} takes a hit: ${labels[idx].toLowerCase()} (sev ${sev}).`, 'combat'); + } + }; + + switch (approach) { + case 'fight': { + // spend ammo + const ammoWeapons = party.filter(p => p.weapon && wpnUsesAmmo(p.weapon)).length; + const spent = Math.min(s.res.ammo, ammoWeapons * 2); + if (spent > 0) { + s.res.ammo -= spent; + say(`Guns speak first — ${spent} ammunition spent in disciplined bursts.`, 'combat'); + } else if (ammoWeapons > 0) { + say('Click. Empty. The guns become clubs very quickly.', 'combat'); + } + const win = rng.f() < fightWinChance(s, enc); + if (win) { + const close = odds.fight < 0.6; + say(close + ? `It's ugly, close, and measured in seconds that feel like hours — but when the noise stops, your people are standing.` + : `${party[0]?.name ?? 'The party'} folds ${enc.enemyName} like bad laundry. Textbook. Terrifying. Effective.`); + finishWin(); + if (close || rng.chance(0.35)) casualty(odds.fight < 0.45 ? 0.6 : 0.2); + } else { + say(`${enc.enemyName} were ready. The line bends, then breaks — your people scatter back toward the fence.`, 'combat'); + casualty(0.7); + if (rng.chance(0.5)) casualty(0.5); + if (enc.context === 'camp') { + const stolenFood = Math.min(4, s.res.food); s.res.food -= stolenFood; + const stolenMed = Math.min(2, s.res.medicine); s.res.medicine -= stolenMed; + s.camp.integrity = Math.max(10, s.camp.integrity - 12); + say(`They load your supplies laughing: -${stolenFood} food, -${stolenMed} medicine. The fence will need work.`, 'bad'); + applyMorale(s, party[0], -10); + } else { + const dropped = Math.min(2, s.res.food); s.res.food -= dropped; + if (dropped) say(`You drop ${dropped} food to buy distance. Alive beats proud.`, 'bad'); + } + } + break; + } + case 'hide': { + const ok = rng.f() < (odds.hide ?? 0) / 100; + if (ok) { + say(`Stillness wins. ${party.map(p => p.name).join(', ')} become part of the ruin's furniture while ${enc.enemyName} pass close enough to hear breathing — yours, slow and practiced.`); + if (rng.chance(0.3)) { const d = Math.min(1, s.res.food); s.res.food -= d; if (d) say('One ration gets left behind under a shelf. Cheap tuition.'); } + for (const p of party) if (rng.chance(0.3) && p.skills.scavenge < 10) p.skills.scavenge++; + } else { + say(`A loose stone. A cough. ${enc.enemyName} turn as one. It becomes a fight you didn't choose.`, 'combat'); + resolveAutoFight(s, rng, say, enc, party, 0.8, casualty, finishWin); + } + break; + } + case 'run': { + const ok = rng.f() < (odds.run ?? 0) / 100; + if (ok) { + say(`Discretion. ${party.map(p => p.name).join(' and ')} break contact and run like the past is gaining — because it is. Alive, empty-handed, smarter.`); + applyMorale(s, party[0], -3); + } else { + const rear = party[party.length - 1]; + if (rear) { + const deathP = 0.1; + if (rng.chance(deathP)) { + killAndGrief(s, rear, `run down by ${enc.enemyName}`, session); + } else { + rear.injuries.push({ kind: 'cut', label: 'Caught at the rear', severity: 2, daysLeft: 6 }); + rear.hp = Math.max(5, rear.hp - 16); + say(`${rear.name} covers the retreat and pays for everyone's speed: caught at the shoulder, bleeding but free.`, 'combat'); + } + } + } + break; + } + case 'negotiate': { + const give = odds.tribute; + const total = (give.food ?? 0) + (give.materials ?? 0); + s.res.food -= give.food ?? 0; + s.res.materials -= give.materials ?? 0; + void total; + const ok = rng.f() < (odds.negotiate ?? 0) / 100; + if (ok) { + say(`Words cost less than blood. You hand over the tribute and ${enc.enemyName} melt back into the ruins, business concluded.`); + applyMorale(s, party[0], -2); + s.flags.karma = (s.flags.karma ?? 0); + } else { + say(`They take the offering — then smile and raise the price. It was never a negotiation. Fight!`, 'combat'); + resolveAutoFight(s, rng, say, enc, party, 0.88, casualty, finishWin); + } + break; + } + } + + return { lines }; + }); +} + +function resolveAutoFight( + _s: GameState, rng: Rng, say: ReturnType, enc: Encounter, + party: Survivor[], powerMult: number, + casualty: (bias: number) => void, finishWin: () => void, +) { + // quick auto-resolved engagement at reduced power + const w = WEATHER[_s.weather]; + let power = 0; + for (const p of party) { + let base = 8 + effSkill(p, 'combat') * 7 + (p.weapon ? wpnPower(p.weapon) : 0); + base *= 0.75 + p.morale / 200; + base *= 0.65 + p.hp / 280; + power += base; + } + power *= powerMult * w.combat; + const win = rng.f() < clamp01(power / (power + enc.strength * 1.1)); + if (win) { say(`Somehow, impossibly, your side holds. ${enc.enemyName} withdraw, licking math they didn't expect.`, 'combat'); finishWin(); if (rng.chance(0.5)) casualty(0.5); } + else { say(`Everything goes wrong at once. Your people break and run, leaving things behind they'll miss.`, 'combat'); casualty(0.8); if (rng.chance(0.4)) casualty(0.5); } +} + +/* ============================================================ + EVENTS firing + ============================================================ */ + +/** Events due right now (queued + story beats), in order. */ +export function collectDueEvents(s: GameState): EventDef[] { + const out: EventDef[] = []; + + // Day 100 finale injects first + if (s.day >= 100 && !s.seenEvents['finale_day']) { + s.seenEvents['finale_day'] = s.day; + out.push(getEvent('finale_day')!); + } + + const remaining: typeof s.queuedEvents = []; + for (const q of s.queuedEvents) { + if (q.dueDay <= s.day) { + if (q.eventId.startsWith('dyn:')) { + const def = buildDynEvent(q.eventId, q.payloadName); + if (def) out.push(def); + } else { + const def = getEvent(q.eventId); + if (def) { + if (def.weight > 0) s.seenEvents[def.id] = s.day; + out.push(def); + } + } + } else remaining.push(q); + } + s.queuedEvents = remaining; + + // story beats + for (const beat of STORY_BEATS) { + if (beat.day !== s.day) continue; + const ok = beat.cond ? beat.cond(s) : true; + const id = ok ? beat.eventId : (beat.altEventId ?? beat.eventId); + if (s.seenEvents[id]) continue; + const def = getEvent(id); + if (def) { + s.seenEvents[id] = s.day; + out.push(def); + } + } + + return out; +} + +/** Pick one random ambient event (called after actions or mornings). */ +export function rollAmbientEvent(s: GameState): EventDef | null { + return withRng(s, (rng) => rollEvent(s, rng)); +} + +/* Cast selection for an event, resolved at fire time. */ +export function castFor(s: GameState, ev: EventDef, rng: Rng) { + const res = ev.cast?.(s); + let speaker: Survivor | null = res?.speaker ?? null; + let extras: string[] = res?.extras ?? []; + if (!speaker) { + const alive = aliveOf(s); + speaker = alive.length ? rng.pick(alive) : null; + } + if (ev.id === 'beat_the_question' || ev.id === 'romance_blooms' || ev.id === 'camp_argument') { + extras = extras.length ? extras : []; + if (ev.id === 'beat_the_question' && !extras.length) { + const alive = aliveOf(s); + extras = [alive[rng.i(0, Math.max(0, alive.length - 1))]?.id ?? '']; + } + } + return { speaker, extras }; +} + +export interface EventRunResult { + lines: LogEntry[]; + encountersQueued: number; +} + +/** Run one choice of an event; returns narration. */ +export function runEventChoice(s: GameState, ev: EventDef, choiceIdx: number, cast: { speaker: Survivor | null; extras: string[] }): EventRunResult { + const choice = ev.choices[choiceIdx]; + if (!choice) return { lines: [], encountersQueued: 0 }; + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const ctx = makeCtx(s, rng, { speaker: cast.speaker, extras: cast.extras }, { lines }); + choice.run(ctx); + return { lines, encountersQueued: s.pending.length }; + }); +} + +/* ============================================================ + NIGHT RESOLUTION + ============================================================ */ + +export interface NightReport { + lines: LogEntry[]; + gameOver: boolean; +} + +export function endDay(s: GameState): NightReport { + return withRng(s, (rng) => { + const lines: LogEntry[] = []; + const say = (t: string, k: LogEntry['kind'] = 'info') => { lines.push({ day: s.day, text: t, kind: k }); s.log.push({ day: s.day, text: t, kind: k }); }; + const alive = aliveOf(s); + const w = WEATHER[s.weather]; + + /* --- eating --- */ + if (alive.length) { + let need = alive.length + Math.round(alive.length * w.foodNeed * 0.5); + need = Math.max(1, need); + if (s.res.food >= need) { + s.res.food -= need; + for (const t of alive) t.hunger = Math.min(100, t.hunger + 45); + say(`Supper is real: ${need} food served hot. For an hour, the camp is just people eating.`); + } else { + // feed the hungriest first — consequences fall on whoever's last + const order = [...alive].sort((a, b) => a.hunger - b.hunger); + let left = s.res.food; + s.res.food = 0; + const unfed: Survivor[] = []; + for (const t of order) { + if (left > 0) { left--; t.hunger = Math.min(100, t.hunger + 45); } + else unfed.push(t); + } + if (unfed.length) { + say(`Not enough food. ${unfed.map(u => u.name).join(', ')} go to bed hungry and pretending not to mind.`, 'bad'); + for (const u of unfed) { u.hunger = Math.max(0, u.hunger - 18); applyMorale(s, u, -7); u.memories.push({ day: s.day, text: 'Skipped supper so others could eat.', tone: 'bad' }); } + } + } + } + + /* --- water --- */ + if (alive.length) { + const need = alive.length + Math.round(alive.length * w.waterNeed * 0.5); + if (s.res.water >= need) { + s.res.water -= need; + } else { + s.res.water = 0; + for (const t of alive) { t.hp = Math.max(1, t.hp - 9); applyMorale(s, t, -7); } + say(`Water runs out. Everyone drinks less than they need and feels it by morning.`, 'bad'); + } + } + + /* --- hunger attrition --- */ + for (const t of [...alive]) { + const drain = hasTrait(t, 'ironStomach') ? 9 : 12; + t.hunger = Math.max(0, t.hunger - drain); + if (t.hunger <= 10) { + t.hp = Math.max(0, t.hp - 8); + applyMorale(s, t, -4); + if (t.hp <= 0) killAndGrief(s, t, 'starved — the rations ran out', { lines }); + } + if (t.sick) { + t.hp = Math.max(0, t.hp - 4); + if (t.hp <= 0) killAndGrief(s, t, 'taken by sickness', { lines }); + } + } + + /* --- medical station overnight treatment --- */ + const medLvl = s.camp.buildings.medical ?? 0; + if (medLvl >= 2 && s.res.medicine > 0) { + const worst = [...alive].sort((a, b) => (b.sick ? 1 : 0) - (a.sick ? 1 : 0) || bInj(a) - bInj(b))[0]; + if (worst && ((worst.sick) || bInj(worst) > 0)) { + s.res.medicine--; + worst.sick = false; + const doc = alive.find(x => x.occ === 'doctor' || x.occ === 'nurse'); + if (worst.injuries.length) worst.injuries.shift(); + worst.hp = Math.min(100, worst.hp + 14); + say(`${doc ? doc.name : 'The medical station'} works by lamplight — ${worst.name} patched and fever-broken by morning.`, 'good'); + } + } + + /* --- natural healing --- */ + for (const t of alive) { + let heal = 4 + (medLvl >= 3 ? 3 : 0); + if (hasTrait(t, 'tough')) heal += 2; + if (hasTrait(t, 'fragile')) heal -= 2; + if (s.flags.restTonight) heal += 4; + if (t.hunger > 40 && !t.sick) t.hp = Math.min(100, t.hp + heal); + for (const inj of t.injuries) inj.daysLeft--; + const before = t.injuries.length; + t.injuries = t.injuries.filter(i => i.daysLeft > 0); + if (t.injuries.length < before) say(`${t.name}'s wounds close enough to stop complaining about.`, 'good'); + if (t.sick && rng.chance(0.3 + medLvl * 0.08)) { t.sick = false; say(`${t.name}'s fever finally breaks for good.`, 'good'); } + } + + /* --- production --- */ + const colLvl = s.camp.buildings.water_collector ?? 0; + if (colLvl > 0) { + let water = [0, 2, 4, 7][colLvl]; + if (s.weather === 'rain') water += 3; + if (s.weather === 'storm') water += 6; + s.res.water += water; + say(`Water collectors drip out +${water} water${s.weather === 'storm' ? ' — the storm\u2019s only gift' : ''}.`); + } + const garLvl = s.camp.buildings.garden ?? 0; + if (garLvl > 0) { + let food = [0, 1, 3, 5][garLvl]; + if (alive.some(x => hasTrait(x, 'greenThumb'))) food += 1; + if ((s.flags.garden_hit ?? 0) > 0) { food = Math.max(0, food - 2); s.flags.garden_hit--; } + if (food > 0) { s.res.food += food; say(`The garden gives +${food} food${(s.flags.garden_hit ?? 0) > 0 ? ' (blight-damaged beds)' : ''}.`); } + } + if (s.flags.well) { s.res.water += 2; } + const genLvl = s.camp.buildings.generator ?? 0; + if (genLvl > 0) { + if (s.res.fuel > 0) { + s.res.fuel -= 1; + if (s.weather === 'cold' && genLvl >= 2) say('The generator hums; the heater keeps the frost outside where it belongs.'); + } else { + say('The generator sits silent — no fuel. The dark is louder than usual.', 'bad'); + for (const t of alive) applyMorale(s, t, -3); + } + } + clampRes(s); + + /* --- morale drift --- */ + for (const t of alive) { + let base = 50; + if (hasTrait(t, 'optimist')) base += 12; + if (hasTrait(t, 'pessimist')) base -= 12; + if (t.personality === 'joker') base += 4; + if (t.personality === 'loner') base -= 3; + base += moraleFloor(s); + if (t.hunger < 25) base -= 10; + if (t.injuries.some(i => i.severity >= 3)) base -= 8; + if (t.coupleWith) base += 6; + const drift = Math.sign(base - t.morale) * Math.min(4, Math.ceil(Math.abs(base - t.morale) * 0.18)); + applyMorale(s, t, drift); + // funny & compassionate lift others slightly + if (hasTrait(t, 'funny') && rng.chance(0.3)) for (const o of alive) if (o.id !== t.id) applyMorale(s, o, 1); + } + + /* --- relationship drift --- */ + for (let i = 0; i < alive.length; i++) { + for (let j = i + 1; j < alive.length; j++) { + const key = relKey(alive[i].id, alive[j].id); + const v = s.rel[key] ?? 0; + if (alive[i].coupleWith === alive[j].id) addRel(s, alive[i].id, alive[j].id, 1); + else if (v > 0) addRel(s, alive[i].id, alive[j].id, v > 60 ? 0 : -1); + else if (v < -50) addRel(s, alive[i].id, alive[j].id, 1); + } + } + + /* --- night incidents --- */ + nightIncidents(s, rng, say); + + /* --- weather damage --- */ + if (s.weather === 'storm') { + s.stats.nightsStorm++; + const dmg = Math.max(0, rng.i(4, 12) - (s.camp.buildings.shelter ?? 0) * 3); + if (dmg > 0) { + s.camp.integrity = Math.max(10, s.camp.integrity - dmg); + say(`Storm tears at the camp through the night. Integrity -${dmg}.`, 'bad'); + } + } + + /* --- rest flag consumed --- */ + if (s.flags.restTonight) { + delete s.flags.restTonight; + for (const t of alive) applyMorale(s, t, 3); + } + + /* --- advance day --- */ + s.day += 1; + s.weather = s.tomorrowWeather; + s.tomorrowWeather = rollNextWeather(rng, s); + s.ap = apMaxOf(s); + s.apMax = s.ap; + s.merchantToday = rng.chance(0.24); + s.log.push({ day: s.day, kind: 'info', text: `— DAY ${s.day} — ${WEATHER[s.weather].icon} ${WEATHER[s.weather].label}` }); + say(`${WEATHER[s.weather].icon} Morning of Day ${s.day}. ${WEATHER[s.weather].label} — ${WEATHER[s.weather].desc}`, 'info'); + + /* --- death sweep / game over --- */ + const stillAlive = aliveOf(s); + if (!stillAlive.length) { + s.phase = 'gameover'; + return { lines, gameOver: true }; + } + return { lines, gameOver: false }; + }); +} + +function bInj(t: Survivor): number { + return t.injuries.reduce((m, i) => Math.max(m, i.severity), 0); +} + +function rollNextWeather(rng: Rng, s: GameState) { + const table: [keyof typeof WEATHER, number][] = + s.day < 15 + ? [['clear', 34], ['rain', 20], ['fog', 14], ['storm', 10], ['heat', 12], ['cold', 10]] + : [['clear', 26], ['rain', 18], ['fog', 13], ['storm', 17], ['heat', 10], ['cold', 16]]; + let w = rng.weighted(table, t => t[1])[0]; + if (rng.chance(0.4)) w = s.weather; + return w; +} + +function nightIncidents(s: GameState, rng: Rng, say: (t: string, k?: LogEntry['kind']) => void) { + const alive = aliveOf(s); + if (!alive.length) return; + const gm = groupMorale(s); + + // theft by own people + if (gm < 40 && rng.chance(0.16)) { + const suspects = alive.filter(t => hasTrait(t, 'greedy') || t.morale < 30); + if (suspects.length) { + const thief = rng.pick(suspects); + const amount = Math.min(2, s.res.food); + if (amount > 0) { + s.res.food -= amount; + say(`Rations walk away in the night. You find ${thief.name} chewing in the dark. Nobody prosecutes; everybody remembers. (-${amount} food)`, 'bad'); + thief.memories.push({ day: s.day, text: 'Got caught stealing food at night.', tone: 'bad' }); + s.flags.karma = (s.flags.karma ?? 0) - 1; + } + } + } + + // arguments + if (gm < 50 && rng.chance(0.2) && alive.length >= 2) { + const [a, b] = rng.pickN(alive, 2); + const key = relKey(a.id, b.id); + if (hasTrait(a, 'hotheaded') || hasTrait(b, 'hotheaded') || rng.chance(0.5)) { + addRel(s, a.id, b.id, -6); + say(`${a.name} and ${b.name} tear strips off each other over nothing at all. The nothing adds up.`, 'bad'); + } else { + addRel(s, a.id, b.id, 3); + say(`${a.name} and ${b.name} argue, then end up splitting the last chocolate bar like diplomats.`); + } + } + + // quiet romance moments + const couplesPotential = alive.filter(t => !t.coupleWith); + if (couplesPotential.length >= 2 && rng.chance(0.12)) { + const [a, b] = rng.pickN(couplesPotential, 2); + const v = s.rel[relKey(a.id, b.id)] ?? 0; + if (v > 45) { + addRel(s, a.id, b.id, 6); + say(`Late watch, shared blanket. ${a.name} and ${b.name} stop pretending it's about the temperature.`, 'good'); + a.memories.push({ day: s.day, text: `Quiet hours with ${b.name}.`, tone: 'good' }); + b.memories.push({ day: s.day, text: `Quiet hours with ${a.name}.`, tone: 'good' }); + } + } + + // desertion attempt + for (const t of [...alive]) { + if (t.morale < 14 && !hasTrait(t, 'loyal') && rng.chance(0.3)) { + if (hasTrait(t, 'greedy')) { + const stolen = Math.min(3, s.res.food); + s.res.food -= stolen; + t.gone = true; t.goneDay = s.day; t.goneReason = 'Deserted with supplies'; + say(`${t.name} is gone at first light — and so is ${stolen} food. Deserved, maybe. Still stings.`, 'bad'); + } else { + t.gone = true; t.goneDay = s.day; t.goneReason = 'Walked away in the night'; + say(`${t.name} leaves a folded note and takes only a canteen. The note says thank you, which somehow makes it worse.`, 'bad'); + } + s.log.push({ day: s.day, text: `${t.name} deserted.`, kind: 'bad' }); + for (const o of aliveOf(s)) if (o.id !== t.id) applyMorale(s, o, -6); + } + } + + // camp dog catches a thief + if (s.flags.camp_dog && rng.chance(0.1)) { + say(`Something tried the storage wall tonight. The dog announced it at length. The something left quickly. Good girl.`); + } + + // grief confrontation + const grieving = alive.filter(t => (s.flags[`grief_${t.id}`] ?? 0) > 0 && t.morale < 38); + if (grieving.length && rng.chance(0.35)) { + const g = rng.pick(grieving); + const about = s.strFlags[`grief_about_${g.id}`]; + if (hasTrait(g, 'hotheaded') || g.personality === 'schemer' || rng.chance(0.5)) { + applyMorale(s, g, -4); + say(`${g.name} finally says it out loud, voice shaking: "${about} would be alive if we'd been smarter." The fire pops. Nobody has an answer that fits in words.`, 'bad'); + g.memories.push({ day: s.day, text: `Blamed the leadership for ${about}. Out loud.`, tone: 'bad' }); + s.flags.blame_leader = (s.flags.blame_leader ?? 0) + 1; + } else { + applyMorale(s, g, 3); + say(`${g.name} asks quietly whether ${about} suffered. You tell the truth gently. It helps a little, the way bandages help.`, 'info'); + } + } +} + +/* ============================================================ + Dynamic (runtime-generated) events + ============================================================ */ + +export function buildDynEvent(eventId: string, payloadName?: string): EventDef | null { + // format: dyn:funeral::: + const [, kind, ...rest] = eventId.split(':'); + if (kind === 'funeral') { + const [nameEnc, causeEnc, occEnc] = rest; + const name = decodeURIComponent(nameEnc ?? 'The dead'); + const cause = decodeURIComponent(causeEnc ?? ''); + const occ = decodeURIComponent(occEnc ?? ''); + return { + id: eventId, title: `${name}'s Funeral`, tag: 'grief', weight: 0, music: 'sad', + text: () => `They bury ${name} — ${occ}, ${cause} — on the little rise above the camp, where the ground is soft and the view tries to be worth something.\n\nThe grave is neat. The words are hard.`, + choices: [ + { + label: 'Speak for them', + run: (ctx) => { + ctx.say(`You talk about ${name} — small true things, a joke they'd steal, the day they fixed the collector in the rain. People laugh once, wetly, which is the point.`); + ctx.morale('all', 5); + ctx.flag('hope', 1); + }, + }, + { + label: 'Stand in silence', + run: (ctx) => { + ctx.say('No speeches. Hats off, heads down, wind saying what it says. Grief keeps its own counsel.'); + ctx.morale('all', 0); + }, + }, + { + label: 'Plant something on the grave', + req: { res: { materials: 2 } }, + run: (ctx) => { + ctx.lose('materials', 2); + ctx.say(`You build a small frame over ${name} and plant the hardiest seeds you have. If anything grows there next month, it grows because of them.`); + ctx.flag('hope', 1); + ctx.morale('all', 3); + }, + }, + ], + }; + } + void payloadName; + return null; +} + +/* phase exposure for UI */ +export { phaseOf }; + +function eligiblePublic(s: GameState, e: EventDef): boolean { return eligible(s, e); } diff --git a/src/engine/state.ts b/src/engine/state.ts new file mode 100644 index 0000000..714c032 --- /dev/null +++ b/src/engine/state.ts @@ -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 { + 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(); + 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 = {}; + 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; +} diff --git a/src/engine/types.ts b/src/engine/types.ts new file mode 100644 index 0000000..57a88c9 --- /dev/null +++ b/src/engine/types.ts @@ -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 = { + 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; + 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; // 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; + + survivors: Survivor[]; + nextSurvivorNum: number; + + camp: CampState; + + locations: LocationInst[]; + + groups: GroupState[]; + + weather: WeatherId; + tomorrowWeather: WeatherId; + + rel: Record; // "idA|idB" sorted -> -100..100 + + log: LogEntry[]; + + flags: Record; // numeric flags/counters/timers + strFlags: Record; // string flags (names, ids) + + queuedEvents: QueuedEvent[]; + + seenEvents: Record; // 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>; + 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>; // 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>; + 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: (arr: T[]) => T; + pickN: (arr: T[], n: number) => T[]; + shuffle: (arr: T[]) => T[]; + weighted: (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'] }[]; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f998c92 --- /dev/null +++ b/src/main.ts @@ -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); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..0b1b27e --- /dev/null +++ b/src/styles.css @@ -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; } +} diff --git a/src/ui/portraits.ts b/src/ui/portraits.ts new file mode 100644 index 0000000..6462159 --- /dev/null +++ b/src/ui/portraits.ts @@ -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 = { + doctor: '', + nurse: '', + soldier: '', + police: '', + hunter: '', + engineer: '', + mechanic: '', + scavenger: '', + farmer: '', + leader: '', + cook: '', + teacher: '', + student: '', + postal: '', +}; + +function hairPath(style: number, fill: string): string { + switch (style % 6) { + case 0: return ``; + case 1: return ``; + case 2: return ``; + case 3: return ``; + case 4: return ``; + default: return ``; + } +} + +function faceHairPath(style: number, fill: string): string { + switch (style % 4) { + case 0: return ``; + case 1: return ``; + case 2: return ``; + default: return ``; + } +} + +/** 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 = ``; + eyes = ``; + mouth = ``; + } else if (m === 'sad') { + brows = ``; + eyes = ``; + mouth = ``; + } else if (m === 'happy') { + brows = ``; + eyes = ``; + mouth = ``; + } else if (m === 'hurt') { + brows = ``; + eyes = ``; + mouth = ``; + } else { + brows = ``; + eyes = ``; + mouth = ``; + } + + const badge = (OCC_BADGE[s.occ] ?? OCC_BADGE.scavenger).replaceAll('ACCENT', accent); + const injuryMark = m === 'hurt' + ? `` + : ''; + const sickTint = s.sick && s.alive ? `` : ''; + const deadTint = dead ? `` : ''; + + return ` + + + + + + + + + + + + + + + + + + + ${hairPath(s.look.hair, hairC)} + ${s.look.faceHair >= 0 ? faceHairPath(s.look.faceHair, shade(hairC, 8)) : ''} + ${brows}${eyes}${mouth} + + ${badge} + ${injuryMark}${sickTint} + + + ${deadTint} + +`; +} diff --git a/src/ui/scene.ts b/src/ui/scene.ts new file mode 100644 index 0000000..5a75d85 --- /dev/null +++ b/src/ui/scene.ts @@ -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; + 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 = { + 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')}`; +} diff --git a/src/ui/ui.ts b/src/ui/ui.ts new file mode 100644 index 0000000..6661106 --- /dev/null +++ b/src/ui/ui.ts @@ -0,0 +1,1306 @@ +import type { EventDef, GameState, LocationInst, Survivor } from '../engine/types'; +import { createNewGame } from '../engine/state'; +import { createRng, randomSeed } from '../engine/rng'; +import { + apMaxOf, canBuild, collectDueEvents, doBuild, doExplore, doHunt, doRepair, + doRest, doScavenge, doTalk, doTravel, estimateOdds, resolveEncounter, + rollAmbientEvent, runEventChoice, castFor, type Approach, withRng, +} from '../engine/sim'; +import { finalizeEnding, epilogue } from '../engine/ending'; +import { aliveOf, groupMorale } from '../engine/sim-core'; +import { AUTO_SLOT, MAX_SLOTS, deleteSlot, listSlots, loadGame, saveGame } from '../engine/save'; +import { WEATHER, PHASE_LABEL, phaseOf } from '../content/world'; +import { templateById } from '../content/locations'; +import { BUILDINGS } from '../content/buildings'; +import { SKILL_LABEL, RESOURCE_IDS } from '../engine/types'; +import { TRAITS } from '../content/survivors'; +import { PERSONALITIES } from '../content/survivors'; +import { portraitSVG, moodOf } from './portraits'; +import { SceneView, type SceneChar } from './scene'; +import { audio } from '../audio/audio'; +import { ENDINGS } from '../content/events/story'; + +const RESOURCE_META: Record = { + food: { icon: '🥫', label: 'Food' }, + water: { icon: '💧', label: 'Water' }, + medicine: { icon: '💊', label: 'Medicine' }, + fuel: { icon: '⛽', label: 'Fuel' }, + materials: { icon: '🔩', label: 'Materials' }, + ammo: { icon: '🔫', label: 'Ammunition' }, + tools: { icon: '🔧', label: 'Tools' }, +}; + +const TRADE_MENU: { give: [string, number]; get: [string, number]; label: string }[] = [ + { give: ['medicine', 2], get: ['food', 7], label: '2 💊 → 7 🥫' }, + { give: ['food', 5], get: ['fuel', 6], label: '5 🥫 → 6 ⛽' }, + { give: ['materials', 8], get: ['tools', 1], label: '8 🔩 → 1 🔧' }, + { give: ['food', 3], get: ['ammo', 4], label: '3 🥫 → 4 🔫' }, + { give: ['fuel', 3], get: ['medicine', 2], label: '3 ⛽ → 2 💊' }, +]; + +/* ---------------- tiny DOM helpers ---------------- */ + +function h(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (cls) el.className = cls; + if (html !== undefined) el.innerHTML = html; + return el; +} +const $id = (id: string): T => document.getElementById(id) as T; +function btn(label: string, cls = 'btn', onClick?: () => void): HTMLButtonElement { + const b = h('button', cls, label); + if (onClick) b.addEventListener('click', () => { audio.sfx('click'); onClick(); }); + return b; +} + +let toastTimer: number[] = []; +function toast(msg: string, tone: 'info' | 'good' | 'bad' = 'info') { + const box = document.getElementById('toasts'); + if (!box) return; + const t = h('div', `toast ${tone}`, msg); + box.appendChild(t); + requestAnimationFrame(() => t.classList.add('show')); + const tm = window.setTimeout(() => { + t.classList.remove('show'); + setTimeout(() => t.remove(), 400); + }, 2600); + toastTimer.push(tm); + if (toastTimer.length > 12) toastTimer = toastTimer.filter(x => x !== tm); +} + +/* ============================================================ + App + ============================================================ */ + +export class App { + private s: GameState | null = null; + private scene: SceneView | null = null; + private previewKind: string = 'camp'; + private morningQueue: EventDef[] = []; + private modalStack = 0; + + mount(root: HTMLElement) { + try { audio.muted = localStorage.getItem('d100_mute') === '1'; } catch { /* ignore */ } + this.renderTitle(root); + } + + /* ============================ TITLE ============================ */ + + private renderTitle(root: HTMLElement) { + root.innerHTML = ''; + document.body.classList.add('title-mode'); + const wrap = h('div', 'title-screen'); + wrap.innerHTML = ` +
+
+
A survival story in one hundred days
+

100 DAYS AFTER

+

The Grayfall took the world in nine days. You have a hundred to decide what the people you love become.

+
+
Runs are procedurally generated and fully reproducible by seed.
+
`; + root.appendChild(wrap); + + const menu = wrap.querySelector('#title-menu')!; + const hasAuto = !!loadGame(AUTO_SLOT); + + if (hasAuto) { + menu.appendChild(btn('▶ CONTINUE', 'btn primary big', () => { + const st = loadGame(AUTO_SLOT); + if (st) this.startGame(st); + })); + } + menu.appendChild(btn('NEW RUN', `btn ${hasAuto ? '' : 'primary'} big`, () => { + this.showNewRunDialog(); + })); + menu.appendChild(btn('LOAD RUN', 'btn big', () => this.showLoadModal(() => this.rerenderTitle()))); + menu.appendChild(btn('HOW TO SURVIVE', 'btn big', () => this.showHelp())); + const mute = btn(audio.muted ? '🔇 SOUND OFF' : '🔊 SOUND ON', 'btn small', () => { + audio.setMuted(!audio.muted); + (mute.textContent) = audio.muted ? '🔇 SOUND OFF' : '🔊 SOUND ON'; + }); + menu.appendChild(mute); + + // ambient scene behind the menu + const bgCanvas = document.createElement('canvas'); + bgCanvas.id = 'title-canvas'; + wrap.querySelector('.title-bg')!.appendChild(bgCanvas); + this.scene = new SceneView(bgCanvas); + this.scene.resize(); + window.addEventListener('resize', () => this.scene?.resize()); + this.scene.set({ + kind: 'camp', label: '', weather: 'fog', + chars: [], buildings: { shelter: 1 }, integrity: 60, + }); + const track = (e: MouseEvent) => this.scene?.setMouse(e.clientX / window.innerWidth, e.clientY / window.innerHeight); + window.addEventListener('mousemove', track); + } + + private rerenderTitle() { + const root = $id('app'); + this.scene?.destroy(); + this.scene = null; + this.renderTitle(root); + } + + private showNewRunDialog() { + const seedDefault = String(randomSeed()); + const body = h('div'); + body.appendChild(h('p', 'muted', 'Every seed is a different world: different survivors, ruins, weather and strangers. Leave blank for a fresh one.')); + const input = h('input', 'seed-input') as HTMLInputElement; + input.placeholder = `Seed (e.g. ${seedDefault})`; + input.value = ''; + body.appendChild(input); + const err = h('div', 'form-error'); + body.appendChild(err); + const start = (seedStr: string) => { + let seed: number; + if (!seedStr.trim()) seed = randomSeed(); + else { + seed = Number(seedStr.trim()); + if (!Number.isFinite(seed)) { + // hash text seeds + seed = 0; + for (const ch of seedStr.trim()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; + } + if (seed <= 0) { err.textContent = 'Seeds must be positive numbers or text.'; return; } + } + this.closeModal(); + const st = createNewGame(seed >>> 0); + this.startGame(st); + toast(`World generated from seed ${seed >>> 0}`, 'good'); + }; + const row = h('div', 'row gap'); + row.appendChild(btn('BEGIN DAY 1', 'btn primary', () => start(input.value))); + row.appendChild(btn('Cancel', 'btn ghost', () => this.closeModal())); + body.appendChild(row); + input.addEventListener('keydown', e => { if (e.key === 'Enter') start(input.value); }); + this.openModal('New Run', body, false); + } + + private showHelp() { + const b = h('div', 'help'); + b.innerHTML = ` +

Goal. Keep your people alive — and human — until Day 100. What happens that day depends on what you built, learned, earned and lost.

+

Days. Each day you have limited actions. Scavenge ruined places, hunt, explore to find new sites, repair storm damage, rest, talk, trade, travel far — then end the day and face the night ledger: food, water, healing, morale.

+

Survivors. They have skills, traits, goals, moods and memories. They remember favors and slights. Grief has consequences. Feed the hungriest first, and notice who skips supper.

+

Danger. Encounters offer estimates, not promises: fight, hide, run or negotiate. Weapons need ammunition. Storms punish travelers.

+

The story. Radio fragments, found documents and strangers slowly reveal what caused Grayfall — and hint at how Day 100 might end. There is more than one way out, and at least one nobody will tell you about.

`; + this.openModal('Field Notes', b); + } + + /* ============================ GAME SCREEN ============================ */ + + private startGame(s: GameState) { + this.s = s; + document.body.classList.remove('title-mode'); + this.scene?.destroy(); + this.scene = null; + const root = $id('app'); + root.innerHTML = ''; + document.body.classList.remove('title-mode'); + + const layout = h('div', 'game-layout'); + layout.innerHTML = ` +
+
100 DAYS AFTER
+
+
+
+
+ + +
+ +
+
+
+
+ `; + root.appendChild(layout); + + const holder = layout.querySelector('#scene-holder')!; + const canvas = holder.querySelector('#scene') as HTMLCanvasElement; + this.scene = new SceneView(canvas); + this.scene.resize(); + holder.addEventListener('mousemove', (e: MouseEvent) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + this.scene?.setMouse((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); + }); + + layout.querySelector('#btn-endday')!.addEventListener('click', () => this.onEndDay()); + layout.querySelector('#btn-menu')!.addEventListener('click', () => this.showGameMenu()); + + for (const tab of Array.from(layout.querySelectorAll('.tab'))) { + tab.addEventListener('click', () => { + layout.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab)); + const name = (tab as HTMLElement).dataset.tab; + $id('tab-log').classList.toggle('hidden', name !== 'log'); + $id('tab-map').classList.toggle('hidden', name !== 'map'); + audio.sfx('click'); + }); + } + + audio.init(); audio.resume(); + audio.setMood('explore'); + audio.setAmbience(s.weather); + this.refresh(); + toast(`Day ${s.day} — ${WEATHER[s.weather].label}`, 'info'); + } + + private showGameMenu() { + const b = h('div'); + const row1 = h('div', 'row wrap gap'); + row1.appendChild(btn('💾 SAVE SLOT 1', 'btn', () => this.saveToSlot('1'))); + row1.appendChild(btn('💾 SAVE SLOT 2', 'btn', () => this.saveToSlot('2'))); + row1.appendChild(btn('💾 SAVE SLOT 3', 'btn', () => this.saveToSlot('3'))); + b.appendChild(row1); + b.appendChild(h('hr')); + const muteBtn = btn(audio.muted ? '🔇 SOUND OFF' : '🔊 SOUND ON', 'btn', () => { + audio.setMuted(!audio.muted); + muteBtn.textContent = audio.muted ? '🔇 SOUND OFF' : '🔊 SOUND ON'; + }); + b.appendChild(muteBtn); + b.appendChild(btn('📖 FIELD NOTES', 'btn', () => this.showHelp())); + b.appendChild(h('hr')); + b.appendChild(btn('🏳 ABANDON RUN', 'btn danger', () => { + this.closeModal(); + this.confirmBox('Abandon this run?', 'The story ends here. Saves remain until overwritten.', () => { + this.rerenderTitle(); + }); + })); + this.openModal('Camp Ledger', b); + } + + private saveToSlot(slot: string) { + if (!this.s) return; + if (saveGame(slot, this.s)) { toast(`Saved to slot ${slot}`, 'good'); audio.sfx('confirm'); } + else toast('Save failed (storage?)', 'bad'); + this.closeModal(); + } + + private showLoadModal(after?: () => void) { + const b = h('div'); + const metas = listSlots(); + if (!metas.length) b.appendChild(h('p', 'muted', 'No saved runs yet.')); + for (const m of metas) { + const row = h('div', 'save-row'); + const d = new Date(m.ts); + row.innerHTML = `
${m.slot === AUTO_SLOT ? '⟳ AUTOSAVE' : 'SLOT ' + m.slot} · ${m.label} + — ${m.survivors}
${d.toLocaleString()} · seed ${m.seed}
`; + const loadB = btn('LOAD', 'btn primary small', () => { + const st = loadGame(m.slot); + if (st) { this.closeModal(); this.startGame(st); } + else toast('Could not load that slot.', 'bad'); + }); + row.appendChild(loadB); + if (m.slot !== AUTO_SLOT) { + const del = btn('✕', 'btn ghost small'); + del.addEventListener('click', () => { + deleteSlot(m.slot); + del.closest('.save-row')?.remove(); + }); + row.appendChild(del); + } + b.appendChild(row); + } + b.appendChild(h('hr')); + b.appendChild(btn('Close', 'btn ghost', () => { this.closeModal(); after?.(); })); + this.openModal('Load Run', b); + } + + /* ============================ REFRESH ============================ */ + + refresh() { + const s = this.s; + if (!s || !this.scene) return; + const alive = aliveOf(s); + const phase = phaseOf(s.day); + + // HUD + $id('hud-day').innerHTML = `DAY ${Math.min(s.day, 100)}/100`; + const wd = WEATHER[s.weather]; + $id('hud-weather').innerHTML = + `${wd.icon}${wd.label}${wd.desc}
Food +${wd.foodNeed}/pers · Water +${wd.waterNeed}/pers · Loot ×${wd.scavenge}
`; + $id('hud-phase').textContent = PHASE_LABEL[phase]; + + const caps = { food: 80 + (s.camp.buildings.storage ?? 0) * 40, water: 80 + (s.camp.buildings.storage ?? 0) * 40 }; + const resRow = $id('hud-res'); + resRow.innerHTML = ''; + for (const r of RESOURCE_IDS) { + const cap = caps[r as 'food'] ?? (50 + (s.camp.buildings.storage ?? 0) * 25); + const low = (r === 'food' || r === 'water') && s.res[r] < alive.length * 2; + const chip = h('div', `res-chip ${low ? 'low' : ''}`, + `${RESOURCE_META[r].icon}${s.res[r]}${RESOURCE_META[r].label} — cap ${cap}`); + resRow.appendChild(chip); + } + + // AP pips + const apWrap = $id('hud-ap'); + apWrap.innerHTML = ''; + const apMax = apMaxOf(s); + for (let i = 0; i < apMax; i++) { + apWrap.appendChild(h('span', `ap-pip ${i < s.ap ? 'full' : ''}`)); + } + const apLabel = h('span', 'ap-label', `${s.ap}/${apMax}`); + apLabel.className = 'tip-src tip'; + apLabel.dataset.tip = 'Actions left today'; + void apLabel; + ($id('btn-endday') as HTMLButtonElement).disabled = false; + + // survivors + const list = $id('survivor-list'); + list.innerHTML = ''; + for (const sv of [...alive].sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))) { + const card = h('div', 'surv-card'); + const mood = moodOf(sv); + card.innerHTML = ` +
${portraitSVG(sv, mood, 52)}
+
+
${sv.name}${sv.weapon ? ' ' + wpnName(sv.weapon) + '' : ''}
+
${sv.occLabel} · ${sv.age}
+
+ ${bar(sv.hp, '#c95d63', 'HP')} + ${bar(sv.hunger, '#d9a441', 'Food')} + ${bar(sv.morale, '#5da9a1', 'Mor')} +
+
${statusChips(sv)}
+
`; + card.addEventListener('click', () => this.showSurvivorDetail(sv)); + list.appendChild(card); + } + for (const sv of s.survivors.filter(x => !x.alive)) { + const card = h('div', 'surv-card dead'); + card.innerHTML = `
${portraitSVG(sv, 'sad', 40)}
+
† ${sv.name}
Died Day ${sv.deathDay} — ${sv.deathCause ?? ''}
`; + list.appendChild(card); + } + + // morale meter + const gm = groupMorale(s); + $id('hud-morale').innerHTML = `GROUP MORALE +
${gm}`; + + // action bar + this.renderActions(); + + // journal + const logEl = $id('tab-log'); + logEl.innerHTML = ''; + const entries = s.log.slice(-70).reverse(); + let lastDay = -1; + for (const e of entries) { + if (e.day !== lastDay) { + lastDay = e.day; + logEl.appendChild(h('div', 'log-day', `DAY ${e.day}`)); + } + logEl.appendChild(h('div', `log-line ${e.kind}`, e.text.replaceAll('\n', '
'))); + } + + // map + this.renderMap(); + + // scene + const previewLoc = s.locations.find(l => l.templateId === this.previewKind); + const chars: SceneChar[] = alive.map((x, i) => ({ + name: x.name, + color: ['#7d5a48', '#48586d', '#5d6b48', '#6d4858', '#54586d', '#6d6048'][i % 6], + })); + this.scene.set({ + kind: this.previewKind, + label: this.previewKind === 'camp' ? 'Base Camp' : (previewLoc ? templateById(previewLoc.templateId).name : ''), + weather: s.weather, + chars, + buildings: s.camp.buildings, + integrity: s.camp.integrity, + }); + audio.setAmbience(s.weather); + } + + private renderActions() { + const s = this.s!; + const bar = $id('actionbar'); + bar.innerHTML = ''; + const mk = (label: string, sub: string, fn: () => void, opts: { disabled?: boolean; tip?: string; primary?: boolean } = {}) => { + const b = h('button', `action ${opts.primary ? 'primary' : ''} ${opts.disabled ? 'disabled' : ''}`, + `${label}${sub}${opts.tip ? `${opts.tip}` : ''}`); + if (!opts.disabled) b.addEventListener('click', () => { audio.sfx('click'); fn(); }); + return b; + }; + + const noAp = s.ap <= 0; + const known = s.locations.filter(l => l.discovered && !l.cleared); + const stormLock = s.weather === 'storm'; + + bar.appendChild(mk('SCAVENGE', `${known.length} site${known.length === 1 ? '' : 's'}`, + () => this.showPartyPicker('scavenge'), { + disabled: noAp || !known.length, + tip: !known.length ? 'Nothing left to search nearby — explore first.' : + noAp ? 'No actions left today.' : 'Search a known location for supplies.', + })); + bar.appendChild(mk('EXPLORE', 'find new sites', () => this.showExplorerPicker(), { + disabled: noAp, tip: 'Send someone into the gray to discover new locations.', + })); + bar.appendChild(mk('HUNT', 'food from the wild', () => this.showPartyPicker('hunt'), { + disabled: noAp, tip: noAp ? 'No actions left today.' : 'Track game in the treeline.', + })); + bar.appendChild(mk('BUILD', 'camp upgrades', () => this.showBuildMenu(), { + disabled: noAp, tip: noAp ? 'No actions left today.' : 'Raise or upgrade camp structures.', + })); + bar.appendChild(mk('TALK', 'hearts & minds', () => this.showTalkPicker(), { + disabled: noAp || aliveOf(s).length < 2, tip: 'Counsel one survivor or strengthen a bond.', + })); + bar.appendChild(mk('TRADE', s.merchantToday ? 'merchant here!' : 'no trader', () => this.showTrade(), { + disabled: noAp || !s.merchantToday, + tip: s.merchantToday ? 'The caravan broker is at the fence today.' : 'Traders come randomly. Watch for them.', + })); + bar.appendChild(mk('REPAIR', '2 🔩 → integrity', () => this.actRepair(), { + disabled: noAp || s.res.materials < 2, tip: `Integrity ${s.camp.integrity}/100.`, + })); + bar.appendChild(mk('REST', 'recover tonight', () => this.actRest(), { + disabled: noAp, tip: 'Everyone heals extra and sleeps properly tonight.', + })); + bar.appendChild(mk('TRAVEL', 'far expedition (2 AP)', () => this.showTravelPicker(), { + disabled: noAp || s.ap < 2, tip: 'Push beyond the valley. Costs the whole day' + + (s.flags.vehicle ? ' — vehicle available: 1 ⛽' : '. On foot is exhausting.'), + })); + + // ambient chance note + if (!noAp && Math.random === undefined) { /* noop */ } + } + + private renderMap() { + const s = this.s!; + const el = $id('tab-map'); + el.innerHTML = ''; + const tiers: [string, number[]][] = [['NEAR', [1]], ['FARTHER', [2]], ['THE WIDE WORLD', [3]]]; + for (const [label, tt] of tiers) { + const locs = s.locations.filter(l => tt.includes(l.tier)); + const known = locs.filter(l => l.discovered); + if (!known.length && s.day < 8) continue; + const sec = h('div', 'map-tier'); + sec.appendChild(h('div', 'map-tier-label', `${label} (${known.length}/${locs.length})`)); + for (const loc of locs) { + if (!loc.discovered) { + sec.appendChild(h('div', 'map-entry unknown', `???undiscovered${loc.tier === 3 && s.day < 12 && !s.flags.vehicle ? ' — too far until Day 12 or a vehicle' : ''}`)); + continue; + } + const tmpl = templateById(loc.templateId); + const entry = h('div', `map-entry ${loc.cleared ? 'cleared' : ''}`); + const riskDots = Math.round(Math.min(5, loc.risk / 18)); + entry.innerHTML = ` +
${loc.name} + ${'◆'.repeat(riskDots)}${'◇'.repeat(5 - riskDots)} + ${loc.specialId && !loc.specialFound ? '' : ''} + ${loc.cleared ? 'picked clean' : ''} +
+
`; + entry.addEventListener('mouseenter', () => { this.previewKind = loc.templateId; this.refreshSceneOnly(); }); + entry.addEventListener('mouseleave', () => { this.previewKind = 'camp'; this.refreshSceneOnly(); }); + sec.appendChild(entry); + } + el.appendChild(sec); + } + // camp status + const campSec = h('div', 'map-tier'); + campSec.appendChild(h('div', 'map-tier-label', 'CAMP')); + campSec.appendChild(h('div', 'map-entry static', ` +
Structures
+
${BUILDINGS.map(b => { + const lv = s.camp.buildings[b.id] ?? 0; + return `${b.icon} ${b.name} ${lv ? 'L' + lv : ''}`; + }).join('')}
+
Integrity +
${s.camp.integrity}
`)); + el.appendChild(campSec); + } + + private refreshSceneOnly() { + const s = this.s!; + const previewLoc = s.locations.find(l => l.templateId === this.previewKind); + const chars: SceneChar[] = aliveOf(s).map((x, i) => ({ + name: x.name, + color: ['#7d5a48', '#48586d', '#5d6b48', '#6d4858', '#54586d', '#6d6048'][i % 6], + })); + this.scene?.set({ + kind: this.previewKind, + label: this.previewKind === 'camp' ? 'Base Camp' : (previewLoc ? templateById(previewLoc.templateId).name : ''), + weather: s.weather, + chars, + buildings: s.camp.buildings, + integrity: s.camp.integrity, + }); + } + + /* ============================ ACTIONS ============================ */ + + private actRepair() { + const s = this.s!; + const r = doRepair(s); + if (!r.ok) { audio.sfx('deny'); return; } + audio.sfx('build'); + this.showReport('Repair', r.lines, () => this.afterAction()); + } + + private actRest() { + const s = this.s!; + const r = doRest(s); + if (!r.ok) { audio.sfx('deny'); return; } + this.showReport('Rest', r.lines, () => this.afterAction()); + } + + private afterAction() { + this.closeModal(); + this.refresh(); + // random ambient event pressure + const s = this.s!; + if (s.phase !== 'planning') return; + if (s.ap <= 0 && Math.random() < 0.35) { + const ev = rollAmbientEvent(s); + if (ev) this.showEvent(ev); + } + this.checkEndState(); + } + + private showPartyPicker(kind: 'scavenge' | 'hunt') { + const s = this.s!; + const body = h('div'); + let chosenSite: LocationInst | null = null; + + if (kind === 'scavenge') { + const sites = s.locations.filter(l => l.discovered && !l.cleared); + const siteWrap = h('div', 'site-grid'); + for (const loc of sites) { + const tmpl = templateById(loc.templateId); + const card = h('div', `site-card ${chosenSite === loc ? 'sel' : ''} ${stormLock(loc) ? 'locked' : ''}`, + `${loc.name}${tmpl.short} + risk ${Math.round(loc.risk)} · ${loc.cleared ? 'empty' : loc.lootPool > 900 ? 'renewable' : 'loot ' + loc.lootPool} + ${loc.specialId && !loc.specialFound ? ' · ✦ hidden' : ''}`); + if (stormLock(loc)) { + card.classList.add('locked'); + card.title = 'Unreachable in this storm.'; + } else { + card.addEventListener('click', () => { + siteWrap.querySelectorAll('.site-card').forEach(c => c.classList.remove('sel')); + card.classList.add('sel'); + chosenSite = loc; + audio.sfx('click'); + }); + } + siteWrap.appendChild(card); + } + body.appendChild(h('div', 'field-label', 'WHERE')); + body.appendChild(siteWrap); + if (!sites.some(l => !stormLock(l))) { + body.appendChild(h('p', 'muted', 'Everything reachable is either picked clean or cut off by the storm.')); + } + } else { + body.appendChild(h('p', 'muted', 'Hunters read the treeline. More skilled hunters, more food — and more eyes for trouble.')); + } + + body.appendChild(this.partyChecklist()); + const foot = h('div', 'row gap end'); + foot.appendChild(btn('Cancel', 'btn ghost', () => this.closeModal())); + foot.appendChild(btn(kind === 'scavenge' ? 'HEAD OUT' : 'TRACK', 'btn primary', () => { + const party = this.checkedIds(); + if (!party.length) { toast('Choose at least one survivor.', 'bad'); return; } + if (kind === 'scavenge') { + if (!chosenSite) { toast('Choose a location.', 'bad'); return; } + this.previewKind = chosenSite.templateId; + const r = doScavenge(s, chosenSite.uid, party); + if (!r.ok) { audio.sfx('deny'); toast('Cannot scavenge there now.', 'bad'); return; } + this.previewKind = 'camp'; + audio.sfx('loot'); + this.showReport(`Scavenging — ${templateById(chosenSite.templateId).name}`, r.lines, () => { + if (r.firedEvent) this.showEvent(r.firedEvent); + else { this.drainEncountersThen(() => this.afterAction()); } + }); + } else { + const r = doHunt(s, party); + if (!r.ok) { audio.sfx('deny'); return; } + this.showReport('Hunt', r.lines, () => this.drainEncountersThen(() => this.afterAction())); + } + })); + body.appendChild(foot); + this.openModal(kind === 'scavenge' ? 'Scavenge Run' : 'Hunt', body); + + function stormLock(_l: LocationInst) { return s.weather === 'storm' && _l.tier >= 3; } + } + + private partyChecklist(excludeId?: string): HTMLElement { + const s = this.s!; + const wrap = h('div', 'party-check'); + wrap.appendChild(h('div', 'field-label', 'WHO GOES')); + for (const sv of aliveOf(s)) { + if (sv.id === excludeId) continue; + const row = h('label', 'check-row'); + const cb = h('input') as HTMLInputElement; + cb.type = 'checkbox'; + cb.value = sv.id; + row.appendChild(cb); + row.insertAdjacentHTML('beforeend', `${sv.name} + ${skillWord(sv)} + ${condWord(sv)}`); + wrap.appendChild(row); + } + return wrap; + } + + private checkedIds(): string[] { + return Array.from(document.querySelectorAll('.party-check input[type=checkbox]:checked')) + .map(el => (el as HTMLInputElement).value); + } + + private showExplorerPicker() { + const s = this.s!; + const body = h('div', 'picker-list'); + body.appendChild(h('p', 'muted', 'Exploration reveals new locations — closer ones first. The wide world unlocks around Day 12, sooner with a vehicle.')); + for (const sv of aliveOf(s)) { + const row = h('div', 'pick-row'); + row.innerHTML = `${portraitSVG(sv, undefined, 40)}
${sv.name} · Scavenging ${sv.skills.scavenge}, sharp eyes find safer paths
`; + row.addEventListener('click', () => { + const r = doExplore(s, sv.id); + this.closeModal(); + if (!r.ok) { toast('There is nothing left to discover.', 'info'); return; } + audio.sfx('confirm'); + this.showReport('Exploration', r.lines, () => this.drainEncountersThen(() => this.afterAction())); + }); + body.appendChild(row); + } + this.openModal('Explore', body); + } + + private showTravelPicker() { + const s = this.s!; + const targets = s.locations.filter(l => l.tier === 3 && !l.cleared && (l.discovered || s.day >= 12 || !!s.flags.vehicle)); + const body = h('div'); + if (!targets.length) { body.appendChild(h('p', 'muted', 'No far destinations known yet.')); } + const grid = h('div', 'site-grid'); + let chosen: LocationInst | null = null; + for (const loc of targets) { + const card = h('div', 'site-card', `${loc.discovered ? loc.name : 'Unknown destination'} + ${loc.discovered ? templateById(loc.templateId).short : 'An expedition will find what\'s out there.'} + ${loc.discovered ? `risk ${Math.round(loc.risk)} · loot ${loc.cleared ? 0 : loc.lootPool}` : 'the unknown'}`); + card.addEventListener('click', () => { + grid.querySelectorAll('.site-card').forEach(c => c.classList.remove('sel')); + card.classList.add('sel'); chosen = loc; audio.sfx('click'); + }); + grid.appendChild(card); + } + if (targets.length) body.appendChild(grid); + body.appendChild(this.partyChecklist()); + const foot = h('div', 'row gap end'); + foot.appendChild(btn('Cancel', 'btn ghost', () => this.closeModal())); + foot.appendChild(btn('TRAVEL (2 AP)', 'btn primary', () => { + if (!chosen) { toast('Choose a destination.', 'bad'); return; } + const party = this.checkedIds(); + if (!party.length) { toast('Choose at least one survivor.', 'bad'); return; } + const dest = chosen as LocationInst; + const r = doTravel(s, dest.uid, party); + if (!r.ok) { audio.sfx('deny'); toast('Not enough actions.', 'bad'); return; } + this.closeModal(); + this.showReport('Expedition', r.lines, () => this.drainEncountersThen(() => this.afterAction())); + })); + body.appendChild(foot); + this.openModal('Travel Far', body); + } + + private showBuildMenu() { + const s = this.s!; + const body = h('div', 'build-grid'); + for (const def of BUILDINGS) { + const cur = s.camp.buildings[def.id] ?? 0; + const { affordable, nextLevel, cost } = canBuild(s, def.id); + const maxed = cur >= def.maxLevel; + const card = h('div', `build-card ${affordable && !maxed ? '' : 'off'}`); + const costHtml = Object.entries(cost).map(([k, v]) => + `${RESOURCE_META[k]?.icon ?? '?'}${v}`).join(' '); + card.innerHTML = ` +
${def.icon}${def.name} + ${Array.from({ length: def.maxLevel }, (_, i) => ``).join('')}
+
${maxed ? def.levels[cur - 1].desc : def.levels[nextLevel - 1].desc}
+
${def.effectText}
+
${maxed ? 'MAXED' : costHtml}
`; + if (affordable && !maxed) { + card.addEventListener('click', () => { + const r = doBuild(s, def.id); + if (!r.ok) { audio.sfx('deny'); return; } + audio.sfx('build'); + this.showReport(`${def.name} L${cur + 1}`, r.lines, () => this.afterAction()); + }); + } + body.appendChild(card); + } + const closeRow = document.createElement('div'); + closeRow.className = 'row end'; + closeRow.appendChild(btn('Close', 'btn ghost', () => this.closeModal())); + body.appendChild(closeRow); + this.openModal('Build & Upgrade', body); + } + + private showTalkPicker() { + const s = this.s!; + const body = h('div'); + body.appendChild(h('p', 'muted', 'Counsel someone who is struggling — or seat two people together and let a bond grow.')); + const list = h('div', 'picker-list'); + for (const sv of aliveOf(s)) { + const row = h('div', 'pick-row'); + row.innerHTML = `${portraitSVG(sv, undefined, 40)} +
${sv.name} +
${PERSONALITIES[sv.personality].desc}
`; + row.addEventListener('click', () => this.showTalkSecondStep(sv)); + list.appendChild(row); + } + body.appendChild(list); + this.openModal('Talk', body); + } + + private showTalkSecondStep(target: Survivor) { + const s = this.s!; + const body = h('div'); + body.appendChild(h('p', '', `${target.name} — ${target.goalText}`)); + const c1 = btn('🪑 Sit with them (counsel)', 'btn primary', () => { + const r = doTalk(s, target.id); + this.closeModal(); + this.showReport('Talk', r.lines, () => this.afterAction()); + }); + body.appendChild(c1); + body.appendChild(h('div', 'field-label', '…OR STRENGTHEN A BOND WITH')); + const list = h('div', 'picker-list'); + for (const other of aliveOf(s)) { + if (other.id === target.id) continue; + const v = relValue(s, target.id, other.id); + const row = h('div', 'pick-row', `${portraitSVG(other, undefined, 36)}
${other.name} + ${v > 0 ? '+' : ''}${v} + ${target.coupleWith === other.id ? '' : ''}
`); + row.addEventListener('click', () => { + const r = doTalk(s, target.id, other.id); + this.closeModal(); + this.showReport('Talk', r.lines, () => this.afterAction()); + }); + list.appendChild(row); + } + body.appendChild(list); + body.appendChild(btn('Back', 'btn ghost', () => this.showTalkPicker())); + this.openModal('Talk — ' + target.name, body); + } + + private showTrade() { + const s = this.s!; + const body = h('div'); + body.appendChild(h('p', 'muted', '“Fair rates. No guns pointed, no knives out. The old way.” — the broker waits, ledger open.')); + for (const t of TRADE_MENU) { + const [gRes, gN] = t.give; const [, getN] = t.get; + const can = s.res[gRes as keyof typeof s.res] >= gN; + const row = h('div', 'trade-row'); + row.innerHTML = `${t.label}you have ${s.res[gRes as keyof typeof s.res]}`; + row.appendChild(btn('TRADE', 'btn small primary', () => { + s.res[gRes as keyof typeof s.res] -= gN; + s.res[t.get[0] as keyof typeof s.res] += getN; + s.stats.tradesDone++; + s.flags.karma = (s.flags.karma ?? 0) + 1; + audio.sfx('loot'); + toast(`Traded ${t.label}`, 'good'); + this.closeModal(); + this.refresh(); + })); + if (!can) { (row.querySelector('button') as HTMLButtonElement).disabled = true; } + body.appendChild(row); + } + body.appendChild(btn('Done trading', 'btn ghost', () => this.closeModal())); + this.openModal('The Caravan Broker', body); + } + + /* ============================ MODALS ============================ */ + + private modalRoot: HTMLElement | null = null; + + private ensureModalRoot() { + if (!this.modalRoot) { + this.modalRoot = h('div', 'modal-root'); + document.body.appendChild(this.modalRoot); + } + } + + /** Opens a modal card. Returns overlay element. */ + private openModal(title: string, body: HTMLElement, closable = true, cls = ''): HTMLElement { + this.ensureModalRoot(); + this.modalStack++; + const overlay = h('div', `overlay ${cls}`); + const card = h('div', 'modal-card'); + const head = h('div', 'modal-head'); + head.appendChild(h('h2', '', title)); + if (closable) { + const x = h('button', 'modal-x', '✕'); + x.addEventListener('click', () => this.closeModal()); + head.appendChild(x); + } + card.appendChild(head); + card.appendChild(body); + overlay.appendChild(card); + this.modalRoot!.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('show')); + return overlay; + } + + private closeModal() { + if (!this.modalRoot) return; + const overlays = this.modalRoot.querySelectorAll(':scope .overlay'); + const last = overlays[overlays.length - 1]; + if (last) { + last.classList.remove('show'); + setTimeout(() => last.remove(), 220); + this.modalStack = Math.max(0, this.modalStack - 1); + } + } + + private closeAllModals() { + if (!this.modalRoot) return; + this.modalRoot.innerHTML = ''; + this.modalStack = 0; + } + + private confirmBox(title: string, text: string, onYes: () => void) { + const b = h('div'); + b.appendChild(h('p', '', text)); + const row = h('div', 'row gap'); + row.appendChild(btn('Yes', 'btn danger', () => { this.closeModal(); onYes(); })); + row.appendChild(btn('No', 'btn', () => this.closeModal())); + b.appendChild(row); + this.openModal(title, b); + } + + private showReport(title: string, lines: { text: string; kind?: string }[], onContinue?: () => void) { + this.closeAllModals(); + const b = h('div', 'report'); + for (const l of lines) { + b.appendChild(h('p', `rep-line ${l.kind ?? 'info'}`, l.text.replaceAll('\n', '
'))); + } + b.appendChild(btn('CONTINUE ⏵', 'btn primary wide', () => { this.closeModal(); onContinue?.(); })); + this.openModal(title, b, false); + } + + /* ---------------- encounters ---------------- */ + + private drainEncountersThen(done: () => void) { + const s = this.s!; + if (!s.pending.length) { done(); return; } + this.showEncounter(() => this.drainEncountersThen(done)); + } + + private showEncounter(done: () => void) { + const s = this.s!; + const enc = s.pending[0]; + if (!enc) { done(); return; } + const odds = estimateOdds(s, enc); + const b = h('div', 'encounter'); + + b.appendChild(h('div', 'enc-title', enc.enemyName)); + b.appendChild(h('p', 'enc-desc', enc.enemyDesc)); + b.appendChild(h('p', 'enc-intro', enc.intro)); + + const partyNames = enc.party.map(id => s.survivors.find(x => x.id === id)?.name ?? '?').join(', '); + b.appendChild(h('p', 'enc-party muted small', `On the spot: ${partyNames} — ${WEATHER[s.weather].icon} ${WEATHER[s.weather].label}`)); + + const oddsRow = h('div', 'odds-row'); + const odd = (label: string, pct: number | null, extra?: string) => { + const cell = h('div', 'odd-cell'); + cell.innerHTML = pct === null + ? `${label}` + : `${pct}%${label}${extra ? ` ${extra}` : ''}`; + return cell; + }; + oddsRow.appendChild(odd('Fight', odds.fight)); + oddsRow.appendChild(odd('Hide', odds.hide)); + oddsRow.appendChild(odd('Run', odds.run)); + if (odds.negotiate !== null) { + const tribute = Object.entries(odds.tribute).map(([k, v]) => `${v}${RESOURCE_META[k]?.icon ?? k}`).join(' '); + oddsRow.appendChild(odd('Negotiate', odds.negotiate, tribute)); + } + b.appendChild(oddsRow); + b.appendChild(h('p', 'muted small center', 'Estimates. Not promises.')); + + const pick = (approach: Approach) => { + audio.sfx(approach === 'fight' ? 'shot' : 'click'); + const res = resolveEncounter(s, approach); + const rb = h('div', 'report'); + for (const l of res.lines) rb.appendChild(h('p', `rep-line ${l.kind}`, l.text.replaceAll('\n', '
'))); + rb.appendChild(btn('CONTINUE ⏵', 'btn primary wide', () => { this.closeModal(); this.refresh(); done(); })); + this.closeAllModals(); + this.openModal(enc.enemyName, rb, false); + }; + + const row = h('div', 'row wrap gap center'); + const mkA = (label: string, approach: Approach, pct: number | null) => { + const bb = btn(label, 'btn choice', () => pick(approach)); + if (pct === null) { (bb as HTMLButtonElement).disabled = true; bb.title = 'Not possible here.'; } + row.appendChild(bb); + }; + mkA('⚔ FIGHT', 'fight', odds.fight); + mkA('👁 HIDE', 'hide', odds.hide); + mkA('🏃 RUN', 'run', odds.run); + mkA('🤝 NEGOTIATE', 'negotiate', odds.negotiate); + b.appendChild(row); + this.openModal('ENCOUNTER', b, false, 'enc-modal'); + } + + /* ---------------- events ---------------- */ + + showEvent(def: EventDef) { + const s = this.s!; + if (def.weight > 0) s.seenEvents[def.id] = s.day; + if (def.music) { + const mood: string = def.music; + audio.setMood(mood as 'tense' | 'hopeful' | 'sad' | 'explore' | 'title'); + } + const cast = withRng(s, rng => castFor(s, def, rng)); + this.renderEventStage(def, cast); + } + + private renderEventStage(def: EventDef, cast: { speaker: Survivor | null; extras: string[] }) { + const s = this.s!; + const b = h('div', 'event-body'); + const speaker = cast.speaker; + + const portWrap = h('div', 'event-port'); + if (speaker && !['stranger_wounded', 'stranger_revenge', 'teen_alone'].includes(def.id)) { + portWrap.innerHTML = portraitSVG(speaker, undefined, 110); + portWrap.appendChild(h('div', 'event-port-name', speaker.name)); + } else { + portWrap.innerHTML = `
👤
`; + } + b.appendChild(portWrap); + const textEl = h('div', 'event-text', def.text(s, cast).replaceAll('\n', '
')); + b.appendChild(textEl); + + const choicesWrap = h('div', 'event-choices'); + def.choices.forEach((ch, i) => { + const req = ch.req; + const reqBadges: string[] = []; + let ok = true; + if (req?.res) for (const [k, v] of Object.entries(req.res)) { + const have = s.res[k as keyof typeof s.res] ?? 0; + reqBadges.push(`${have}/${v} ${RESOURCE_META[k]?.icon ?? ''}`); + if (have < (v ?? 0)) ok = false; + } + if (req?.skill) { + const sk = req.skill; + const best = Math.max(...aliveOf(s).map(x => x.skills[sk.skill])); + reqBadges.push(`${SKILL_LABEL[sk.skill]} ${best}/${sk.min}`); + if (best < sk.min) ok = false; + } + if (req?.flag) { ok = ok && !!s.flags[req.flag]; } + if (req?.minFlag) { + reqBadges.push(`${s.flags[req.minFlag.key] ?? 0}/${req.minFlag.min} ${req.minFlag.key.replaceAll('_', ' ')}`); + if ((s.flags[req.minFlag.key] ?? 0) < req.minFlag.min) ok = false; + } + if (req?.noFlag && s.flags[req.noFlag]) ok = false; + + const el = h('button', `ev-choice ${ok ? '' : 'locked'}`, + `${ch.label}${reqBadges.length ? `${reqBadges.join(' · ')}` : ''}${ch.tip ? `${ch.tip}` : ''}`); + if (ok) { + el.addEventListener('click', () => { + audio.sfx('confirm'); + const result = runEventChoice(s, def, i, cast); + this.showEventOutcome(def, result.lines, cast); + }); + } else { + el.addEventListener('click', () => audio.sfx('deny')); + } + choicesWrap.appendChild(el); + }); + // deadlock guard: every event must leave at least one path forward + if (def.choices.length && !choicesWrap.querySelector('.ev-choice:not(.locked)')) { + const lastResort = h('button', 'ev-choice', 'Steel yourself and carry on'); + lastResort.addEventListener('click', () => { + audio.sfx('click'); + this.showEventOutcome(def, [{ text: 'None of the paths open to you today. You keep moving anyway.', kind: 'info' }], cast); + }); + choicesWrap.appendChild(lastResort); + } + + b.appendChild(choicesWrap); + this.closeAllModals(); + this.openModal(def.title.toUpperCase(), b, false, 'event-modal'); + } + + private showEventOutcome(def: EventDef, lines: { text: string; kind?: string }[], _cast: unknown) { + this.closeAllModals(); + const b = h('div', 'report'); + for (const l of lines) b.appendChild(h('p', `rep-line ${l.kind ?? 'info'}`, l.text.replaceAll('\n', '
'))); + if (!lines.length) b.appendChild(h('p', 'rep-line info', 'It is done.')); + b.appendChild(btn('CONTINUE ⏵', 'btn primary wide', () => { + this.closeModal(); + this.refresh(); + const s = this.s!; + if (s.pending.length) { + this.drainEncountersThen(() => this.postEvent()); + } else { + this.postEvent(); + } + })); + this.openModal(def.title.toUpperCase(), b, false); + } + + /** After an event resolves: continue morning queue or return to play. */ + private postEvent() { + const s = this.s!; + this.refresh(); + if (this.morningQueue.length) { + const next = this.morningQueue.shift()!; + this.showEvent(next); + return; + } + if (s.phase !== 'planning') { this.checkEndState(); return; } + this.checkEndState(); + if (!aliveOf(s).length) return; + // occasional ambient event in the quiet of planning + if (Math.random() < 0.25) { + const ev = rollAmbientEvent(s); + if (ev) this.showEvent(ev); + } + } + + /* ---------------- day cycle ---------------- */ + + private onEndDay() { + const s = this.s!; + if (s.pending.length) { this.drainEncountersThen(() => this.onEndDay()); return; } + const rep = endOfDaySafe(s); + if (!rep) return; + saveGame(AUTO_SLOT, s); + this.refresh(); + this.showReport(`NIGHT — Day ${Math.max(1, s.day - 1)}`, rep.lines, () => { + if (rep.gameOver || !aliveOf(s).length) { + finalizeEnding(s); + this.showEnding(); + return; + } + // new morning + audio.sfx('day'); + this.morningQueue = collectDueEvents(s); + this.previewKind = 'camp'; + this.refresh(); + if (this.morningQueue.length) { + const next = this.morningQueue.shift()!; + this.showEvent(next); + } else if (s.day >= 100 && !s.flags.finale_path) { + const fin = collectDueEvents(s); + if (fin.length) this.showEvent(fin[0]); + } else { + this.checkEndState(true); + } + toast(`Day ${Math.min(s.day, 100)} — ${WEATHER[s.weather].label}`, 'info'); + }); + } + + /** Returns true if the finale should fire. */ + private checkEndState(morning = false) { + const s = this.s!; + if (s.phase === 'gameover' || s.endingId) { this.showEnding(); return true; } + if (!aliveOf(s).length) { finalizeEnding(s); this.showEnding(); return true; } + // finale resolution: path set, no outstanding encounter, hold resolved + if (s.flags.finale_path) { + const holdResolved = s.flags.finale_path !== 3 || !!s.flags.hold_fight_done; + if (!s.pending.length && holdResolved && !this.modalStack) { + finalizeEnding(s); + this.showEnding(); + return true; + } + return false; + } + if (s.day >= 100 && morning) { + const fin = collectDueEvents(s); + if (fin.length) { this.showEvent(fin[0]); return true; } + } + return false; + } + + private showEnding() { + const s = this.s!; + if (!s.endingId) finalizeEnding(s); + const ending = ENDINGS[s.endingId!] ?? ENDINGS.wanderers; + audio.stopThunderLoop(); + audio.setMood(s.endingId === 'silence' ? 'sad' : ending.tone === 'transcendent' || ending.tone === 'good' ? 'hopeful' : 'sad'); + const b = h('div', 'ending-body tone-' + ending.tone); + b.innerHTML = ` +
DAY ${Math.min(s.day, 100)} — THE STORY OF YOUR HUNDRED DAYS
+

${ending.title}

+
${ending.body.replaceAll('\n', '
')}
`; + const eps = epilogue(s); + const table = h('div', 'epilogue'); + table.appendChild(h('div', 'ep-title', 'THE PEOPLE')); + for (const e of eps) { + table.appendChild(h('div', `ep-row ${e.tone}`, `${e.name}${e.fate}`)); + } + b.appendChild(table); + + const stats = h('div', 'end-stats'); + const stat = (k: string, v: string | number) => `
${v}${k}
`; + stats.innerHTML = + stat('days survived', Math.min(s.day, 100)) + + stat('survivors left', aliveOf(s).length) + + stat('lost along the way', s.stats.lost) + + stat('fights won', s.stats.kills) + + stat('scavenging runs', s.stats.scavengedRuns) + + stat('trades', s.stats.tradesDone) + + stat('karma', s.flags.karma ?? 0) + + stat('hope', Math.max(0, s.flags.hope ?? 0)); + b.appendChild(stats); + b.appendChild(h('p', 'muted small center', `seed ${s.seed} — share it to challenge a friend to survive the same world better.`)); + + const row = h('div', 'row gap center'); + row.appendChild(btn('↺ NEW RUN', 'btn primary big', () => { + this.closeAllModals(); + this.showNewRunDialog(); + })); + row.appendChild(btn('MAIN MENU', 'btn big', () => { + this.closeAllModals(); + this.s = null; + this.rerenderTitle(); + })); + b.appendChild(row); + this.closeAllModals(); + this.openModal('', b, false, 'ending-modal'); + } + + /* ---------------- survivor detail ---------------- */ + + private showSurvivorDetail(sv: Survivor) { + const s = this.s!; + const b = h('div', 'sd-body'); + const left = h('div', 'sd-left', portraitSVG(sv, undefined, 150)); + left.appendChild(h('div', 'sd-mood muted small', 'feeling ' + moodOf(sv))); + b.appendChild(left); + + const right = h('div', 'sd-right'); + right.innerHTML = ` +

${sv.name} ${sv.age} · ${sv.occLabel}

+
${PERSONALITIES[sv.personality].label} — ${PERSONALITIES[sv.personality].desc}
+
🎯 ${sv.goalText}
+
+ ${bar(sv.hp, '#c95d63', 'Health', true)} + ${bar(sv.hunger, '#d9a441', 'Fed', true)} + ${bar(sv.morale, '#5da9a1', 'Morale', true)} +
`; + if (sv.injuries.length) { + right.appendChild(h('div', 'field-label', 'INJURIES')); + for (const inj of sv.injuries) { + right.appendChild(h('div', `inj-row sev${inj.severity}`, `${inj.label}severity ${inj.severity} · ~${inj.daysLeft}d`)); + } + } + right.appendChild(h('div', 'field-label', 'SKILLS')); + const skills = h('div', 'skill-grid'); + for (const [k, v] of Object.entries(sv.skills)) { + skills.appendChild(h('div', 'skill', `${SKILL_LABEL[k as keyof typeof SKILL_LABEL]} +
${v}`)); + } + right.appendChild(skills); + + right.appendChild(h('div', 'field-label', 'TRAITS')); + const traits = h('div', 'trait-chips'); + for (const t of sv.traitIds) { + traits.insertAdjacentHTML('beforeend', `${TRAITS[t].label}`); + } + right.appendChild(traits); + + right.appendChild(h('div', 'field-label', 'RELATIONSHIPS')); + const rels = h('div', 'rel-list'); + const others = s.survivors.filter(o => o.id !== sv.id && o.alive && !o.gone); + if (!others.length) rels.appendChild(h('span', 'muted small', 'Nobody else is left.')); + for (const o of others) { + const v = relValue(s, sv.id, o.id); + const tag = sv.coupleWith === o.id ? ' ♥ partners' + : v >= 60 ? ' · friends' + : v <= -40 ? ' · rivals' : ''; + rels.insertAdjacentHTML('beforeend', `
${o.name} +
+ ${v > 0 ? '+' : ''}${v}${tag}
`); + } + right.appendChild(rels); + + right.appendChild(h('div', 'field-label', 'MEMORIES')); + const mem = h('div', 'memory-list'); + for (const m of [...sv.memories].slice(-9).reverse()) { + mem.appendChild(h('div', `memory ${m.tone}`, `D${m.day}${m.text}`)); + } + right.appendChild(mem); + b.appendChild(right); + this.closeModal(); + this.openModal(`${sv.name}`, b); + } +} + +/* ---------------- shared helpers ---------------- */ + +function relValue(s: GameState, a: string, b: string): number { + return s.rel[[a, b].sort().join('|')] ?? 0; +} + +function bar(v: number, color: string, label: string, labeled = false): string { + return `
+ ${labeled ? `${label}` : ''} +
`; +} + +function statusChips(sv: Survivor): string { + const chips: string[] = []; + const worstInj = sv.injuries.reduce((m, i) => Math.max(m, i.severity), 0); + if (worstInj > 0) chips.push(`✚ injured`); + if (sv.sick) chips.push(`☣ sick`); + if (sv.hunger < 25) chips.push(`hungry`); + if (sv.morale < 28) chips.push(`broken`); + else if (sv.morale > 76) chips.push(`spirits high`); + if (sv.coupleWith) chips.push(``); + return chips.join(''); +} + +function bestSkillFor(sv: Survivor): number { + return Math.max(...Object.values(sv.skills)); +} + +function skillWord(sv: Survivor): string { + const entries = Object.entries(sv.skills) as [string, number][]; + entries.sort((a, b) => b[1] - a[1]); + const [k, v] = entries[0]; + return `${SKILL_LABEL[k as keyof typeof SKILL_LABEL]} ${v}`; +} + +function condWord(sv: Survivor): string { + if (sv.hp < 40 || sv.injuries.some(i => i.severity >= 2)) return 'wounded'; + if (sv.sick) return 'sick'; + if (sv.morale < 30) return 'low'; + return 'ready'; +} + +function wpnName(id: string): string { + return ({ crowbar: 'Crowbar', machete: 'Machete', pistol: 'Pistol', rifle: 'Rifle', shotgun: 'Shotgun', bat: 'Bat' } as Record)[id] ?? id; +} + +/** wrapper so we never double-run endDay */ +function endOfDaySafe(s: GameState) { + try { + // eslint-disable-next-line + const sim = require_sim(); + return sim.endDay(s) as { lines: { text: string; kind?: string }[]; gameOver: boolean }; + } catch (e) { + console.error(e); + toast('Something went wrong resolving the night.', 'bad'); + return null; + } +} +import * as SIM from '../engine/sim'; +function require_sim() { return SIM; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fd96fbd --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a94c0ec --- /dev/null +++ b/vite.config.ts @@ -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, + }, +});