sindicate 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/casino/blackjack.js +187 -0
  23. package/src/systems/casino/cards3d.js +55 -0
  24. package/src/systems/casino/coins3d.js +75 -0
  25. package/src/systems/casino/table3d.js +130 -0
  26. package/src/systems/climbing.js +136 -0
  27. package/src/systems/coach.js +1034 -0
  28. package/src/systems/combat.js +586 -0
  29. package/src/systems/crime.js +582 -0
  30. package/src/systems/deadeye.js +332 -0
  31. package/src/systems/encounters.js +123 -0
  32. package/src/systems/enemy.js +442 -0
  33. package/src/systems/entity.js +524 -0
  34. package/src/systems/fistCurl.js +38 -0
  35. package/src/systems/footsteps.js +161 -0
  36. package/src/systems/glass.js +67 -0
  37. package/src/systems/herd.js +277 -0
  38. package/src/systems/horse.js +361 -0
  39. package/src/systems/loot.js +303 -0
  40. package/src/systems/missions.js +418 -0
  41. package/src/systems/mountedTraveller.js +518 -0
  42. package/src/systems/nav.js +343 -0
  43. package/src/systems/npc.js +880 -0
  44. package/src/systems/pathFollow.js +107 -0
  45. package/src/systems/platform.js +484 -0
  46. package/src/systems/player.js +1619 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/roadGraph.js +264 -0
  49. package/src/systems/satchel.js +500 -0
  50. package/src/systems/scenes.js +105 -0
  51. package/src/systems/seatedRider.js +396 -0
  52. package/src/systems/shop.js +515 -0
  53. package/src/systems/skybirds.js +129 -0
  54. package/src/systems/train.js +1957 -0
  55. package/src/systems/trainCamera.js +414 -0
  56. package/src/systems/traveller.js +254 -0
  57. package/src/systems/tumbleweeds.js +92 -0
  58. package/src/systems/vat.js +327 -0
  59. package/src/systems/vfx.js +472 -0
  60. package/src/world/blobShadows.js +109 -0
  61. package/src/world/clouds.js +61 -0
  62. package/src/world/flora.js +87 -0
  63. package/src/world/footprints.js +117 -0
  64. package/src/world/lampField.js +58 -0
  65. package/src/world/lampGlow.js +92 -0
  66. package/src/world/scatter.js +1077 -0
  67. package/src/world/sky.js +363 -0
  68. package/src/world/tileManager.js +197 -0
  69. package/src/world/walkHumps.js +74 -0
  70. package/src/world/weather.js +312 -0
@@ -0,0 +1,418 @@
1
+ // missions.js — the mission ENGINE (v2). A mission is an ordered list of STAGES, each a first-class
2
+ // objective (talk · kill · goto · deliver · defend · escort) with a target, a description, a live
3
+ // counter, and optional onEnter/onComplete hooks. The engine runs ONE story spine at a time, advances
4
+ // stage by stage, drives the compass/map objective marker, and only pays out at the end. Definitions
5
+ // (data/missions.js) are immutable; PROGRESS is a small JSON blob persisted to localStorage (auto-save
6
+ // on every stage change) — the basis for save/load. See docs/PLAN_missions_v2.md.
7
+ //
8
+ // Wiring (main.js): registerNpc(npc) for every giver/target NPC, registerHook(id, fn) for the spawn
9
+ // hooks a mission's onEnter needs, then boot() once the world exists. The game feeds events in:
10
+ // enemyKilled(e) from enemy.die, talked(npcId) from a dialogue topic, and update(dt) each frame for
11
+ // the position-based types.
12
+ import * as THREE from 'three/webgpu';
13
+ // Campaign defs, save-key namespace and act title-cards are GAME data:
14
+ // configureMissions({ defs, saveKey, stageCards })
15
+ let MISSION_DEFS = [];
16
+ let KEY = 'sindicate_save_';
17
+ let STAGE_CARDS = {};
18
+ export function configureMissions({ defs = null, saveKey = null, stageCards = null } = {}) {
19
+ if (defs) MISSION_DEFS = defs;
20
+ if (saveKey) KEY = saveKey;
21
+ if (stageCards) STAGE_CARDS = stageCards;
22
+ }
23
+
24
+ // MULTI-SLOT SAVES. localStorage `dustwater_save_<slot>`: slot 'auto' is the running autosave (written
25
+ // silently on every stage change); numbered slots are the player's manual saves from the Save screen.
26
+
27
+ const AUTO = 'auto';
28
+
29
+ // Act-break cards (ui.stageCard) — shown when the spine crosses into a new stage-pack.
30
+ const keyOf = (slot) => KEY + slot;
31
+
32
+ // A small JPEG of the scene for the Load screen. WebGPU won't let drawImage read the canvas, so render
33
+ // the scene to a tiny offscreen target and read it back — the exact pattern tools/map.js uses for its
34
+ // thumbnails (sync render, setRenderTarget(null), buffer RETURNED, top-left origin so no flip). Reuses
35
+ // one target + canvas. Null (→ "no preview") if it can't. Called after a save, patching the slot.
36
+ async function captureThumb(game) {
37
+ try {
38
+ const { renderer, scene, camera } = game;
39
+ if (!renderer?.readRenderTargetPixelsAsync || !scene || !camera) return null;
40
+ const w = 256, h = 144;
41
+ const rt = (captureThumb._rt ||= new THREE.RenderTarget(w, h));
42
+ renderer.setRenderTarget(rt);
43
+ renderer.render(scene, camera);
44
+ renderer.setRenderTarget(null);
45
+ const buf = await renderer.readRenderTargetPixelsAsync(rt, 0, 0, w, h);
46
+ // the RT is LINEAR (no post grade), so it reads dark — lift it to sRGB (gamma 1/2.2) via a LUT so
47
+ // the thumbnail looks like the graded game, not a murky raw pass.
48
+ const lut = (captureThumb._lut ||= (() => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(255 * Math.pow(i / 255, 1 / 2.2)); return t; })());
49
+ for (let i = 0; i < buf.length; i += 4) { buf[i] = lut[buf[i]]; buf[i + 1] = lut[buf[i + 1]]; buf[i + 2] = lut[buf[i + 2]]; }
50
+ const c = (captureThumb._c ||= Object.assign(document.createElement('canvas'), { width: w, height: h }));
51
+ const ctx = c.getContext('2d'), img = ctx.createImageData(w, h);
52
+ img.data.set(buf); // top-left origin — no flip (per map.js)
53
+ ctx.putImageData(img, 0, 0);
54
+ return c.toDataURL('image/jpeg', 0.62);
55
+ } catch (e) { console.warn('[save] thumbnail failed', e?.message ?? e); return null; }
56
+ }
57
+
58
+ export class Missions {
59
+ constructor(game) {
60
+ this.game = game;
61
+ this.defs = MISSION_DEFS;
62
+ this.hooks = {}; // hook id -> fn (onEnter spawners, etc.)
63
+ this.npcs = {}; // def.id -> NPC (givers + talk targets)
64
+ this._stageEnemies = []; // the live hostiles a kill stage is tracking
65
+ this.progress = this._loadKey(keyOf(AUTO)) || this._loadKey(keyOf('1')) || this._fresh(); // resume the autosave (legacy slot 1 as fallback)
66
+ }
67
+
68
+ _fresh() { return { version: 1, money: null, active: MISSION_DEFS[0]?.id ?? null, missions: {}, flags: {}, stats: {} }; } // read at CALL time — a module-level capture predates registration
69
+ // `id` override: county NPCs carry GENERATED ids (`sheriff7`), so campaign givers found by NAME
70
+ // register under a stable alias the mission defs can target ('fisk', 'august', 'rand'…).
71
+ registerNpc(npc, id = null) { const k = id ?? npc?.def?.id; if (k && npc) this.npcs[k] = npc; }
72
+ registerHook(id, fn) { this.hooks[id] = fn; }
73
+
74
+ // STORY FLAGS — the campaign's persistent keys (badge worn, dynamite proven, house for sale…).
75
+ // Written by mission completion (def.flags) or by anything else (the house purchase), saved with
76
+ // the rest of progress.
77
+ flag(name) { return !!(this.progress.flags ??= {})[name]; }
78
+ setFlag(name, v = true) { (this.progress.flags ??= {})[name] = v; this.game._applyCrewState?.(); this._save(); }
79
+
80
+ // STORY STATS — the campaign's sliding scales (trust, reputation). Numbers, not booleans: every
81
+ // hard choice nudges them (topic `stat: { trust: -1 }`), and later stages read them through
82
+ // onlyIf/skipIf-style gates in dialogue (`statMin: { trust: 2 }` / `statMax`) or plain stat().
83
+ stat(name) { return (this.progress.stats ??= {})[name] ?? 0; }
84
+ addStat(name, d) { const s = (this.progress.stats ??= {}); s[name] = (s[name] ?? 0) + d; this.game._applyCrewState?.(); this._save(); }
85
+
86
+ // ── lookups into the active mission ──────────────────────────────────────────────────
87
+ get def() { return this.defs.find((m) => m.id === this.progress.active) || null; }
88
+ _rec() { const a = this.progress.active; return (this.progress.missions[a] ??= { stage: 0, done: false, counters: {} }); }
89
+ get stageIndex() { return this._rec().stage; }
90
+ get stage() { return this.def?.stages[this.stageIndex] || null; }
91
+
92
+ // Called once after the NPCs + world exist. Resumes the saved mission at its stage (silent) and
93
+ // restores the purse. (Position is NOT restored on boot — only via an explicit Load — so a dev
94
+ // reload doesn't yank the player around; a Continue from the title will call loadGame instead.)
95
+ boot() {
96
+ // MIGRATION: a save from before the campaign shipped finished m1 and persisted active: null —
97
+ // under the old defs there was nothing to unlock. Re-open the spine: first mission not yet done
98
+ // whose prerequisites all are (for that save, c1_dust).
99
+ if (!this.progress.active) {
100
+ const next = this.defs.find((m) => !this.progress.missions[m.id]?.done
101
+ && (m.requires || []).every((r) => this.progress.missions[r]?.done));
102
+ if (next) this.progress.active = next.id;
103
+ }
104
+ if (!this.progress.active) return;
105
+ if (this.progress.money != null && this.game.loot) this.game.loot.money = this.progress.money;
106
+ this._enterStage(true);
107
+ }
108
+
109
+ // In-session load (the SYSTEM tab's Load Game): restore progress + purse + position, then rebuild
110
+ // the current stage's world. NOTE: kill counters reset on load — the benched hostiles all respawn
111
+ // (per-enemy kill persistence is a later refinement).
112
+ loadGame(slot) {
113
+ const s = slot != null ? this._loadKey(keyOf(slot)) : this._mostRecent();
114
+ if (!s || !s.active) { this.game.ui?.toast?.('No saved game found.', 3000); return false; }
115
+ this.progress = { version: s.version, money: s.money, active: s.active, missions: s.missions || {}, flags: s.flags || {}, stats: s.stats || {}, player: s.player };
116
+ if (s.money != null && this.game.loot) this.game.loot.money = s.money;
117
+ if (s.playtime != null) this.game._playSeconds = s.playtime;
118
+ if (s.player && this.game.player) {
119
+ this.game.player.position.set(s.player.x, this.game.world.groundAt(s.player.x, s.player.z) + 0.5, s.player.z);
120
+ if (s.player.heading != null) this.game.player.heading = s.player.heading;
121
+ }
122
+ this._enterStage(true);
123
+ this.game.ui?.toast?.('Game loaded.', 2500);
124
+ return true;
125
+ }
126
+
127
+ hasSave() { return this.listSaves().length > 0; } // any save FILE exists (auto or manual)
128
+
129
+ // ── stage lifecycle ──────────────────────────────────────────────────────────────────
130
+ _enterStage(resuming) {
131
+ const s = this.stage;
132
+ if (!s) return;
133
+ this.game._ensurePackBench?.(this.progress.active); // lazy-bench this pack's hostiles (+ the next's)
134
+ this._stageEnemies = [];
135
+ // a talk stage hands its own dialogue to the giver, so his lines match the stage. The NPC's
136
+ // ORIGINAL tree (usually undefined — treeFor falls back to NAMED/ROLES) is stashed on first
137
+ // wrap and restored when the mission completes, so a barkeep goes back to being a MERCHANT
138
+ // and not a man stuck forever on one campaign scene.
139
+ if (s.type === 'talk' && s.tree) {
140
+ const npc = this.npcs[s.target];
141
+ if (npc) {
142
+ if (!('_preTree' in npc.def)) npc.def._preTree = npc.def.tree;
143
+ npc.def.tree = this._wrapTree(s.tree, s.target);
144
+ ((this._wrapped ??= {})[this.progress.active] ??= new Set()).add(s.target);
145
+ }
146
+ }
147
+ // FOLLOW / UNFOLLOW — a stage can put a registered NPC on the player's heel (escorts, walking a
148
+ // recruit home) and take him off it. Tracked per mission so complete() can never leave a man
149
+ // trailing the player for the rest of his life.
150
+ for (const key of [].concat(s.follow ?? [])) {
151
+ const n = this.npcs[key];
152
+ if (n) { n._follow = true; ((this._followers ??= {})[this.progress.active] ??= new Set()).add(key); }
153
+ }
154
+ for (const key of [].concat(s.unfollow ?? [])) { const n = this.npcs[key]; if (n) n._follow = false; }
155
+ // onEnter hook (spawn/teleport a set, open a door…). Runs on resume too — the hooks teleport
156
+ // already-benched entities, so they're idempotent, and a saved kill stage needs its world back.
157
+ if (s.onEnter) this.hooks[s.onEnter]?.(this);
158
+ this._setMarkers();
159
+ // A KILL STAGE WITH NOBODY LEFT TO KILL completes itself. Reachable via an in-session Load of a
160
+ // stale slot (the bench died later in this same session — the hook skips the dead), where the
161
+ // old behavior was worse than a stall: with zero matched hostiles, the NEXT kill of ANY stranger
162
+ // in the county advanced the stage. Skip it cleanly instead — UNLESS the tag's bench is simply
163
+ // still LOADING (pack-gated lazy spawn): then hold; _spawnPackBench re-enters when the men land.
164
+ if (s.type === 'kill' && this._stageEnemies.length === 0) {
165
+ if (this.game._benchPending?.(s.tag)) return;
166
+ this._save(); return this.advance();
167
+ }
168
+ // a stage can open on a SCENE (scenes.js — letterboxed camera beats; plays once per save)
169
+ if (s.scene) this.game.scenes?.play?.(s.scene);
170
+ if (!resuming) {
171
+ if (this.stageIndex === 0 && !this._quietTitle) this.game.ui?.questBanner?.(this.def.title?.toUpperCase?.() || 'NEW JOB');
172
+ if (s.desc) this.game.ui?.toast?.(s.desc, 6500);
173
+ }
174
+ this._save();
175
+ }
176
+
177
+ // Transform a data tree's `complete: true` topic into one that advances this talk stage when picked.
178
+ // `targetId` is the stage's own target key, captured at wrap time — NOT npc.def.id: county NPCs are
179
+ // registered under a stable ALIAS ('fisk', 'august', 'rand') while their def.id is generated, so
180
+ // completing via def.id would never match the stage and the talk would never advance.
181
+ //
182
+ // BRANCHING lives in the topics (the campaign's choices are things you SAY):
183
+ // flag: 'name'|[...] picking this topic sets these story flags (spare/kill, bribe/refuse…)
184
+ // goto: 'stageId' a completing topic can route to a NAMED stage instead of the next one
185
+ // onlyIf / skipIf the topic only appears when the flags allow — earlier choices shape
186
+ // later conversations (evaluated at stage entry, when the tree is wrapped)
187
+ _wrapTree(tree, targetId) {
188
+ const topics = (tree.topics || [])
189
+ .filter((t) => this._condOk(t))
190
+ .map((t) => (t.complete || t.flag || t.stat)
191
+ ? { ...t, action: (npc, dlg) => {
192
+ for (const f of [].concat(t.flag ?? [])) (this.progress.flags ??= {})[f] = true;
193
+ for (const [k, d] of Object.entries(t.stat ?? {})) { const s = (this.progress.stats ??= {}); s[k] = (s[k] ?? 0) + d; }
194
+ this.game._applyCrewState?.();
195
+ if (t.complete) dlg?.ui?.game?.missions?.talked(targetId ?? npc?.def?.id, t.goto);
196
+ } }
197
+ : t);
198
+ return { ...tree, topics };
199
+ }
200
+
201
+ // Gate checks, shared by stages AND topics:
202
+ // onlyIf / skipIf — a flag name or list (onlyIf: all set; skipIf: none set)
203
+ // statMin / statMax — { statName: bound } thresholds (statMin: at least; statMax: at most)
204
+ _condOk(o) {
205
+ const f = this.progress.flags ?? {};
206
+ if (o.onlyIf && ![].concat(o.onlyIf).every((k) => f[k])) return false;
207
+ if (o.skipIf && [].concat(o.skipIf).some((k) => f[k])) return false;
208
+ for (const [k, v] of Object.entries(o.statMin ?? {})) if (this.stat(k) < v) return false;
209
+ for (const [k, v] of Object.entries(o.statMax ?? {})) if (this.stat(k) > v) return false;
210
+ return true;
211
+ }
212
+
213
+ // The active stage's target lights the gold objective marker (compass + map).
214
+ _setMarkers() {
215
+ const s = this.stage;
216
+ // clear old
217
+ for (const id in this.npcs) this.npcs[id].objective = false;
218
+ for (const e of this._stageEnemies) if (e) e.objective = false;
219
+ let marks = [];
220
+ if (!s) { this.game.questMarkers = []; return; }
221
+ if (s.type === 'talk' || s.type === 'deliver') {
222
+ const npc = this.npcs[s.target || s.to];
223
+ if (npc) { npc.objective = true; marks = [npc]; }
224
+ } else if (s.type === 'kill' || s.type === 'defend' || s.type === 'escort') {
225
+ this._stageEnemies = this._matchEnemies(s);
226
+ for (const e of this._stageEnemies) e.objective = true;
227
+ marks = this._stageEnemies.slice();
228
+ if (s.type === 'escort' && this.npcs[s.who]) marks.push(this.npcs[s.who]);
229
+ } else if (s.type === 'goto') {
230
+ marks = [{ position: { x: s.pos.x, y: 0, z: s.pos.z }, objective: true, alive: true, type: 'objective' }];
231
+ }
232
+ this.game.questMarkers = marks;
233
+ }
234
+
235
+ // the live hostiles this stage cares about, by explicit ids or a mission tag stamped at spawn
236
+ _matchEnemies(s) {
237
+ const en = this.game.combat?.enemies ?? [];
238
+ return en.filter((e) => e.alive && (
239
+ (s.targets && s.targets.includes(e.missionId)) || (s.tag && e.missionTag === s.tag)));
240
+ }
241
+
242
+ // ── the event bus the game feeds ─────────────────────────────────────────────────────
243
+ // a hostile died — advance a kill stage when its quota is met
244
+ enemyKilled(e) {
245
+ const s = this.stage;
246
+ if (!s || s.type !== 'kill') return;
247
+ const rec = this._rec();
248
+ const alive = this._matchEnemies(s).length;
249
+ const spawned = s.count ?? this._stageEnemies.length;
250
+ rec.counters[s.id] = Math.min(spawned, spawned - alive);
251
+ this._setMarkers();
252
+ this._save();
253
+ if (alive === 0 && (rec.counters[s.id] >= spawned || this._stageEnemies.length === 0)) this.advance();
254
+ }
255
+
256
+ // the player finished a giver's dialogue that completes the current talk stage. `gotoId` is the
257
+ // branching topic's routing — advance to that named stage instead of the next in order.
258
+ talked(npcId, gotoId = null) {
259
+ const s = this.stage;
260
+ if (s && s.type === 'talk' && s.target === npcId) this.advance(gotoId);
261
+ }
262
+
263
+ // per-frame — position-based completion (goto, escort arrival, defend timer), plus the two gates:
264
+ // timeLimit + onExpire — a stage that can be LOST forward: run out the clock and the mission
265
+ // routes to the named stage instead (the fuse burns down, the wagon rolls)
266
+ // hours: [from, to] — a goto that only completes inside the window (night jobs). Wrapping
267
+ // windows (21→5) welcome.
268
+ update(dt) {
269
+ const s = this.stage;
270
+ if (!s) return;
271
+ if (s.timeLimit && s.onExpire) {
272
+ const rec = this._rec();
273
+ rec.counters[`t_${s.id}`] = (rec.counters[`t_${s.id}`] ?? 0) + dt;
274
+ if (rec.counters[`t_${s.id}`] >= s.timeLimit) { this.game.ui?.toast?.(s.expireDesc ?? 'Too late.', 5000); return this.advance(s.onExpire); }
275
+ }
276
+ const p = this.game.player?.position;
277
+ if (s.type === 'goto' && p) {
278
+ if (s.hours) {
279
+ const h = this.game.world?.sky?.hour ?? 12, [a, b] = s.hours;
280
+ const inWin = a <= b ? (h >= a && h <= b) : (h >= a || h <= b);
281
+ if (!inWin) {
282
+ // standing at the mark at the wrong hour: say so, once — the man at the door at noon
283
+ // shouldn't have to guess that the job wants dark (rest at a fire passes the hours)
284
+ const r = s.radius ?? 6;
285
+ if ((p.x - s.pos.x) ** 2 + (p.z - s.pos.z) ** 2 <= (r + 8) ** 2)
286
+ this.game.hint?.(`hours_${s.id}`, `Not at this hour. This work keeps different ones — rest at a fire to pass the time.`);
287
+ return;
288
+ }
289
+ }
290
+ const r = s.radius ?? 6;
291
+ if ((p.x - s.pos.x) ** 2 + (p.z - s.pos.z) ** 2 <= r * r) this.advance();
292
+ } else if (s.type === 'defend' && s.seconds) {
293
+ const rec = this._rec();
294
+ rec.counters[s.id] = (rec.counters[s.id] ?? 0) + dt;
295
+ if (rec.counters[s.id] >= s.seconds) this.advance();
296
+ } else if (s.type === 'escort') {
297
+ const who = this.npcs[s.who];
298
+ if (who && ((who.position.x - s.to.x) ** 2 + (who.position.z - s.to.z) ** 2) <= (s.radius ?? 6) ** 2) this.advance();
299
+ }
300
+ }
301
+
302
+ // an item reached its destination (called from the delivery interaction when it lands)
303
+ delivered(item, toId) {
304
+ const s = this.stage;
305
+ if (s && s.type === 'deliver' && s.item === item && (s.to === toId || !s.to)) this.advance();
306
+ }
307
+
308
+ // ── advance / complete ───────────────────────────────────────────────────────────────
309
+ // `toId` (branching): jump to the named stage instead of the next in order. Either way, stages
310
+ // whose onlyIf/skipIf don't match this playthrough's flags are stepped over — that's how one
311
+ // mission carries both halves of a fork and each run only walks its own.
312
+ advance(toId = null) {
313
+ const rec = this._rec(), def = this.def, s = this.stage;
314
+ if (s?.onComplete) this.hooks[s.onComplete]?.(this);
315
+ let i = toId != null ? def.stages.findIndex((st) => st.id === toId) : rec.stage + 1;
316
+ if (i < 0) { console.warn('[missions] goto to unknown stage', toId); i = rec.stage + 1; }
317
+ while (i < def.stages.length && !this._condOk(def.stages[i])) i++;
318
+ if (i >= def.stages.length) { this.complete(); return; }
319
+ rec.stage = i;
320
+ this._enterStage(false);
321
+ }
322
+
323
+ complete() {
324
+ const rec = this._rec(), def = this.def;
325
+ rec.done = true;
326
+ if (def.reward?.money && this.game.loot) this.game.loot.money = (this.game.loot.money ?? 0) + def.reward.money;
327
+ if (def.reward?.items?.length) this.game.loot?._giveItems?.(def.reward.items); // [{id,n}] into the satchel, toasted
328
+ for (const f of def.flags ?? []) (this.progress.flags ??= {})[f] = true; // story keys this mission proves
329
+ this.game.ui?.questBanner?.(def.doneBanner ?? 'THE JOB IS DONE');
330
+ if (def.reward?.money) this.game.ui?.toast?.(`$${def.reward.money} — the county pays its debts.`, 8000);
331
+ // Every NPC this mission wrapped goes back to who he was: the stashed original tree (usually
332
+ // undefined → treeFor's NAMED/ROLES fallback, which is how the barkeep gets his BAR back after
333
+ // c1 and August his ammunition counter after c4 — a wrapped tree left in place buried their
334
+ // merchant lines for the rest of the game). Toomey alone keeps a plain post-mission line: his
335
+ // role fallback is the anonymous drifter tree, which reads worse than one tired sentence.
336
+ for (const key of this._wrapped?.[def.id] ?? []) {
337
+ const n = this.npcs[key];
338
+ if (n && '_preTree' in n.def) { n.def.tree = n.def._preTree; delete n.def._preTree; }
339
+ }
340
+ delete this._wrapped?.[def.id];
341
+ // …and nobody keeps following the player out of a finished mission
342
+ for (const key of this._followers?.[def.id] ?? []) { const n = this.npcs[key]; if (n) n._follow = false; }
343
+ delete this._followers?.[def.id];
344
+ this.game._applyCrewState?.(); // completion flags may have recruited someone — seat them at the house
345
+ if (def.giver === 'deputy_toomey' && this.npcs.deputy_toomey) {
346
+ this.npcs.deputy_toomey.def.tree = { openers: [`Town's quieter than it was. There'll be more paper before long.`], topics: [] };
347
+ }
348
+ // unlock the next spine mission (one at a time): first unlock whose prereqs are all done
349
+ const next = (def.unlocks || []).map((id) => this.defs.find((m) => m.id === id))
350
+ .find((m) => m && (m.requires || []).every((r) => this.progress.missions[r]?.done));
351
+ this.progress.active = next ? next.id : null;
352
+ if (next) {
353
+ // crossing into a new STAGE-PACK gets the act-break card (letterbox + act title) on top of
354
+ // everything else — the campaign's between-stages cinematic beat.
355
+ const actBreak = next.id[0] !== def.id[0] && STAGE_CARDS[next.id[0]];
356
+ if (actBreak) setTimeout(() => this.game.ui?.stageCard?.(actBreak[0], actBreak[1]), 2600);
357
+ // enter the next mission with its TITLE BANNER held back — fired synchronously it lands in the
358
+ // same frame as the doneBanner above and stomps it (questBanner replaces immediately). The
359
+ // banner is wall-clock UI chrome, so a wall-clock delay is the right tool; the guard drops it
360
+ // if the player has already moved the story on by then.
361
+ this._rec(); this._quietTitle = true; this._enterStage(false); this._quietTitle = false;
362
+ const nd = this.def;
363
+ setTimeout(() => { if (this.def === nd) this.game.ui?.questBanner?.(nd.title?.toUpperCase?.() || 'NEW JOB'); }, actBreak ? 10200 : 4800);
364
+ } else this._setMarkers(); // no next — clear every objective marker
365
+ this._save();
366
+ }
367
+
368
+ // ── persistence (multi-slot) ──────────────────────────────────────────────────────────
369
+ snapshot() {
370
+ const p = this.game.player?.position;
371
+ return { version: this.progress.version, money: this.game.loot?.money ?? this.progress.money,
372
+ active: this.progress.active, missions: this.progress.missions, flags: this.progress.flags ?? {}, stats: this.progress.stats ?? {},
373
+ player: p ? { x: p.x, z: p.z, heading: this.game.player.heading } : this.progress.player,
374
+ // metadata for the Load screen: date, current job, playtime. (thumb is filled in async below.)
375
+ savedAt: Date.now(), title: this.def?.title ?? 'Dustwater', objective: this.stage?.desc ?? '',
376
+ playtime: this.game._playSeconds ?? 0 };
377
+ }
378
+
379
+ // Write a save. slot 'auto' = the running autosave (silent); a number = a manual save (toasts). The
380
+ // thumbnail is a WebGPU readback (async), so the slot is written now with the PREVIOUS thumb (no
381
+ // flicker) and patched with the fresh one when the pixels land.
382
+ saveGame(slot = AUTO, name = null) {
383
+ const snap = this.snapshot();
384
+ snap.slot = slot; snap.auto = (slot === AUTO);
385
+ snap.name = name != null ? name : (slot === AUTO ? 'Autosave' : snap.title);
386
+ const prev = this._loadKey(keyOf(slot)); if (prev?.thumb) snap.thumb = prev.thumb;
387
+ try { localStorage.setItem(keyOf(slot), JSON.stringify(snap)); } catch (e) { /* private mode / quota */ }
388
+ this.game.ui?.saving?.(); // the SAVING… spinner (auto + manual)
389
+ if (slot !== AUTO) this.game.ui?.toast?.('Game saved.', 2500);
390
+ this._patchThumb(slot);
391
+ return true;
392
+ }
393
+ async _patchThumb(slot) {
394
+ const thumb = await captureThumb(this.game);
395
+ if (!thumb) return;
396
+ try { const raw = localStorage.getItem(keyOf(slot)); if (!raw) return; const s = JSON.parse(raw); s.thumb = thumb; localStorage.setItem(keyOf(slot), JSON.stringify(s)); } catch (e) {}
397
+ }
398
+ _save() { this.saveGame(AUTO); } // auto-persist on every stage change (silent)
399
+
400
+ // every save on disk, newest first, with its slot id — for the Save/Load screen.
401
+ listSaves() {
402
+ const out = [];
403
+ try {
404
+ for (let i = 0; i < localStorage.length; i++) {
405
+ const k = localStorage.key(i);
406
+ if (!k || !k.startsWith(KEY)) continue;
407
+ const s = JSON.parse(localStorage.getItem(k));
408
+ if (s && s.active !== undefined) out.push({ ...s, slot: s.slot ?? k.slice(KEY.length) });
409
+ }
410
+ } catch (e) { /* private mode / bad json */ }
411
+ return out.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
412
+ }
413
+ _mostRecent() { return this.listSaves()[0] || null; }
414
+ deleteSave(slot) { try { localStorage.removeItem(keyOf(slot)); } catch (e) {} }
415
+ _loadKey(k) { try { const s = localStorage.getItem(k); return s ? JSON.parse(s) : null; } catch (e) { return null; } }
416
+
417
+ reset() { this.deleteSave(AUTO); this.progress = this._fresh(); } // New Game: fresh autosave; manual saves are kept
418
+ }