sindicate 0.16.0 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 — towns, and a third game's report card
4
+
5
+ Two halves. Cities — the thing the road system was always for. And the first pass through
6
+ `ISSUES.md`, raised from building a **third** game (MilitaryGame/"Blacksand", a military game on
7
+ Synty + Kubold + Kevin Iglesias packs) against 0.16.0 as a published dependency rather than a
8
+ file link. Every fix in that half defaults to exactly the previous behaviour, so western and
9
+ fable do not move.
10
+
11
+ ### Cities (new: `world/cityPlan.js`, `world/settlement.js`, `world/mapWorld.js`)
12
+
13
+ - `world/cityPlan.js` — a settlement as a street grid whose faces are BLOCKS, zoned. Terrain is a
14
+ veto: water and slope reject a cell outright, population decides how many of the survivors get
15
+ built on, and the town grows outward from its middle so it is one place rather than a rash of
16
+ hamlets. Junctions are classified by arm count — and two arms is **not one case**: two opposite
17
+ arms are a street running through, two perpendicular are a corner.
18
+ - `world/settlement.js` — `planBlockFrontage` fills the blocks, which is the step Kiwi's own
19
+ `BlockData` marks "for building placement later" and never took. Buildings stand on the block
20
+ boundary facing the street; a model's odds decay as it is used, so a pack of sixteen renders as
21
+ sixteen rather than five. `hasFrontage` refuses motorways and dual carriageways outright —
22
+ nothing in the world fronts one.
23
+ - `world/mapWorld.js` — several places and the roads between them. A city exposes `exits`, shaped
24
+ like a road-kit socket, so `buildRoadLink` joins town to motorway with no special case.
25
+ - `world/roadLink.js` — extrudes a **cross-section with height in it** when a socket carries one:
26
+ kerb, gutter and raised footway are in the profile, not laid separately. Four surfaces on their
27
+ own texels, and the past-the-kerb rule that decides which is which.
28
+ - `world/roadTerrain.js` — a corridor now declares whether it carries tarmac (`deck: false` for
29
+ pure earthwork), and coverage stops at the ends of a line instead of claiming a half-disc of
30
+ phantom road beyond them.
31
+
32
+ **The lesson that cost the most, recorded because it will recur:** the earthworks were tuned by
33
+ eye against a height FUNCTION, while every fault lived in the drawn LATTICE. Between two vertices
34
+ straddling a cut the mesh interpolates straight across, so 12 cm of clearance vanishes on a 3.8 m
35
+ cell. Kiwi's own figures — a 1.0 m road bed and a 5 m skirt — are an order of magnitude bigger
36
+ than the ones guessed here for exactly that reason.
37
+
38
+ ### Reported by a third game (`ISSUES.md`, all 14 items)
39
+
40
+ - **A guarded object is not a guarded method.** `game.audio?.loadSamples(map)` guards `audio`
41
+ alone; 59 call sites across 13 files had the same shape. And frame hooks ran unguarded, so one
42
+ throw silently killed every system registered after it — reported as grenades hanging in
43
+ mid-air with frozen fuses, four systems from a footstep sound. Hooks are now isolated, and a
44
+ hook that throws eight times is retired rather than logged at 60 Hz.
45
+ - **Masks follow their source clip.** A game re-registering a logical clip (a weapon switch) kept
46
+ masks pointing at the boot weapon for ever. And a one-shot re-triggered faster than it plays is
47
+ no longer restarted — 200 restarts over 19 s becomes 34.
48
+ - **`climbing.js` referenced an undeclared `REACH`**, so its proximity-mount path threw on sight;
49
+ it also read only fable's `world.castle.ladders`. Both fixed, plus `setLadders()`.
50
+ - **`createCar()` spawned from the default ride height** while every later frame used the
51
+ configured one — a vehicle at 0.9 started 0.56 m low and popped up.
52
+ - Perf timers are both held and cleared; an unset `qualityName` no longer answers a BAD framerate
53
+ by stepping quality **up**.
54
+ - Dead `setTrainContent` keys say so; an unknown walk-hump is scenery rather than a crash, with
55
+ `setHumpDims()` to teach it.
56
+
57
+ ### Declared overrides (new: `core/overrides.js`)
58
+
59
+ Named decisions a game may answer for itself, registered at construction and never patched at
60
+ runtime. It overrides **decisions, not methods** — `play`'s signature is implementation shape and
61
+ will move; "what should the body be playing?" is a question with a stable contract. Registering
62
+ nothing, or answering `null`, is byte-identical to before. Unknown names and non-functions throw
63
+ at construction; an override that throws at runtime is caught and the engine's own answer used.
64
+
65
+ First point: `locomotionClip`. The full-body clip per gun state is right for western's gunslinger
66
+ pack and wrong for Kubold's rifle set, which has one standing reload — so walking into a reload
67
+ stopped the legs dead.
68
+
69
+ ### Also injected rather than assumed
70
+
71
+ - `AudioSystem` takes an `sfxManifest`; western's list is exported as `DUSTWATER_SFX(dirs)`.
72
+ - Seated-pose assets take `setSeatedContent({ rig, clips, audioDir })` — a soldier in a tank hatch
73
+ wants the same retarget as a rider on a horse.
74
+ - The weapon table derives from what the game registered. Only three lines ever named western's
75
+ weapons, and they were what made the constructor throw without both `revolver` and `rifle`.
76
+
77
+ ### Notes
78
+
79
+ - True N-slot weapons, the seat/occupant abstraction, and `city` support in `roadAssert` are
80
+ deliberately **not** in this release. Each is scheduled rather than landed opportunistically.
81
+ - The city modules are new and may still move before 1.0.
82
+
3
83
  ## 0.16.0 — driving on it
4
84
 
5
85
  ### A car (new: `vehicle/`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,9 +10,24 @@
10
10
  // new AudioSystem({ sfx, muted, sfxDirs, storageKey })
11
11
 
12
12
 
13
+ // The default manifest: western's recorded SFX, kept here rather than inline in loadSfx() so
14
+ // that a game replacing it can still see — and reuse — what the engine used to assume.
15
+ export function DUSTWATER_SFX({ combat: C, ui: U } = {}) {
16
+ const map = {};
17
+ for (let i = 1; i <= 6; i++) { map[`swing${i}`] = `${C}/swing${i}.wav`; map[`hit${i}`] = `${C}/hit${i}.wav`; }
18
+ for (let i = 1; i <= 3; i++) map[`block${i}`] = `${C}/block${i}.wav`;
19
+ for (let i = 1; i <= 4; i++) map[`boom${i}`] = `${C}/boom${i}.wav`;
20
+ map.uiclick1 = `${U}/uiclick1.wav`;
21
+ for (let i = 1; i <= 2; i++) map[`coin${i}`] = `${U}/coin${i}.wav`;
22
+ for (let i = 1; i <= 3; i++) map[`orb${i}`] = `${U}/orb${i}.wav`;
23
+ for (const e of ['heal', 'quest', 'questDone', 'renown', 'levelup']) map[`${e}1`] = `${U}/${e}1.wav`;
24
+ return map;
25
+ }
26
+
13
27
  export class AudioSystem {
14
- constructor({ sfx = {}, muted = [], sfxDirs = { combat: '/assets/audio/combat', ui: '/assets/audio/ui' }, storageKey = 'av_volSfx' } = {}) {
28
+ constructor({ sfx = {}, muted = [], sfxDirs = { combat: '/assets/audio/combat', ui: '/assets/audio/ui' }, storageKey = 'av_volSfx', sfxManifest = null } = {}) {
15
29
  this._sfx = sfx;
30
+ this._sfxManifest = sfxManifest;
16
31
  this._muted = new Set(muted);
17
32
  this._sfxDirs = sfxDirs;
18
33
  this._storageKey = storageKey;
@@ -127,19 +142,22 @@ export class AudioSystem {
127
142
  src.start(t0); src.stop(t0 + dur);
128
143
  }
129
144
 
130
- // Lazily load all recorded SFX (combat swing/hit/block/boom + ui/coin/orb/heal/quest/level-up).
131
- // Called from ensure() once the context exists; sample() no-ops until they finish decoding.
145
+ // Lazily load all recorded SFX. Called from ensure() once the context exists; sample()
146
+ // no-ops until they finish decoding.
147
+ //
148
+ // WHICH SAMPLES EXIST IS GAME DATA. This built a fixed list of western's names — swing1..6,
149
+ // hit1..6, block1..3, boom1..4, and the UI cues — and ensure() calls it unconditionally, so
150
+ // a game with different audio took about thirty 404s on its first sound and had no way to
151
+ // stop it but overriding the method. The class comment already said recorded SFX are "GAME
152
+ // data — pass at construction", and `sfx` and `sfxDirs` already arrive that way; the loader
153
+ // simply did not follow.
154
+ //
155
+ // `sfxManifest` is either a { name: url } map or a function of the sfx dirs returning one.
156
+ // Absent, it is western's list exactly as before — so western and fable do not move.
132
157
  loadSfx() {
133
- const C = this._sfxDirs.combat, U = this._sfxDirs.ui;
134
- const map = {};
135
- for (let i = 1; i <= 6; i++) { map[`swing${i}`] = `${C}/swing${i}.wav`; map[`hit${i}`] = `${C}/hit${i}.wav`; }
136
- for (let i = 1; i <= 3; i++) map[`block${i}`] = `${C}/block${i}.wav`;
137
- for (let i = 1; i <= 4; i++) map[`boom${i}`] = `${C}/boom${i}.wav`;
138
- map.uiclick1 = `${U}/uiclick1.wav`;
139
- for (let i = 1; i <= 2; i++) map[`coin${i}`] = `${U}/coin${i}.wav`;
140
- for (let i = 1; i <= 3; i++) map[`orb${i}`] = `${U}/orb${i}.wav`;
141
- for (const e of ['heal', 'quest', 'questDone', 'renown', 'levelup']) map[`${e}1`] = `${U}/${e}1.wav`;
142
- this.loadSamples(map);
158
+ const m = this._sfxManifest;
159
+ const map = typeof m === 'function' ? m(this._sfxDirs) : (m ?? DUSTWATER_SFX(this._sfxDirs));
160
+ this.loadSamples(map ?? {});
143
161
  }
144
162
 
145
163
  // gainMul (default 1) scales the sound's volume — the hook for distance-attenuated one-shots: a
package/src/core/anim.js CHANGED
@@ -57,12 +57,24 @@ export class Animator {
57
57
  const ctx = this._ctxFor(this.clipRigs?.[name] ?? null);
58
58
  const action = this.mixer.clipAction(retargetClip(clip, ctx));
59
59
  this.actions[name] = action;
60
+ // A MASK IS DERIVED FROM A CLIP, so re-registering the clip must rebuild it. Masks were
61
+ // snapshotted at build time and never invalidated, so a game that re-registers a logical
62
+ // name later — one that swaps weapons by re-registering `gunFire`, say — kept masks
63
+ // pointing at whatever was loaded at boot. Every weapon then fired and reloaded like the
64
+ // one you spawned with, silently and for ever. The engine created the derivations, so it
65
+ // is the engine's job to know them.
66
+ for (const d of this._derived?.get(name) ?? []) this.registerMasked(d.name, name, d.keepFn);
60
67
  }
61
68
 
62
69
  // Build a bone-masked copy of an already-registered clip (keeps only tracks whose
63
70
  // bone passes keepFn). Used to split locomotion across body halves — e.g. legs
64
71
  // from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
65
72
  registerMasked(name, srcName, keepFn) {
73
+ // remembered so register() can rebuild it — see the note there
74
+ const list = (this._derived ??= new Map()).get(srcName) ?? [];
75
+ if (!list.some((d) => d.name === name)) list.push({ name, keepFn });
76
+ this._derived.set(srcName, list);
77
+
66
78
  const src = this.actions[srcName];
67
79
  if (!src) return;
68
80
  const clip = src.getClip().clone();
@@ -125,6 +137,16 @@ export class Animator {
125
137
  const timeScale = opts.timeScale ?? tm.speed ?? 1;
126
138
  if (this.currentName === name && !restart && !once) return action;
127
139
 
140
+ // A ONE-SHOT RE-TRIGGERED FASTER THAN IT PLAYS IS NOT A ONE-SHOT. One-shots deliberately
141
+ // skip the early return above so each call restarts them, which is right for a revolver
142
+ // and wrong for an M4: at a 0.095 s rate a 1 s full-body fire clip restarts ten times a
143
+ // second and the whole body jitters. Let it run unless it has advanced far enough to be
144
+ // worth restarting, or the caller insists with `restart`.
145
+ if (once && !restart && this.current === action && action.isRunning()) {
146
+ const dur = action.getClip().duration || 0;
147
+ if (dur > 0 && action.time < dur * (opts.retrigger ?? RETRIGGER_AT)) return action;
148
+ }
149
+
128
150
  action.reset();
129
151
  if (startFrac > 0) action.time = action.getClip().duration * startFrac;
130
152
  action.timeScale = timeScale;
@@ -16,9 +16,13 @@
16
16
  // engine.start() // hands the loop to the renderer
17
17
  import * as THREE from 'three/webgpu';
18
18
  import { tune } from './tune.js';
19
+ import { Overrides } from './overrides.js';
20
+
21
+ // how many times a frame hook may throw before the engine stops calling it
22
+ const HOOK_THROW_LIMIT = 8;
19
23
 
20
24
  export class Engine {
21
- constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false }) {
25
+ constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false, overrides = null }) {
22
26
  this.renderer = renderer;
23
27
  this.scene = scene;
24
28
  this.camera = camera;
@@ -26,6 +30,10 @@ export class Engine {
26
30
  this.post = post;
27
31
  this.isPaused = isPaused;
28
32
  this.isUiModal = isUiModal;
33
+ // NAMED DECISIONS A GAME MAY ANSWER FOR ITSELF — see core/overrides.js. Passing nothing
34
+ // is byte-identical to before; an unknown name or a non-function throws here, at
35
+ // construction, rather than at frame 900.
36
+ this.overrides = new Overrides(overrides);
29
37
 
30
38
  // time
31
39
  this.rawDt = 0;
@@ -148,12 +156,26 @@ export class Engine {
148
156
  start() {
149
157
  this._clockLast = performance.now();
150
158
  this.renderer.setAnimationLoop(this.tick);
151
- // auto perf log — first at 6s (past load hitches), then periodic
152
- setTimeout(() => { this.perfReport(); this._perfTimer = setInterval(() => this.perfReport(), 15000); }, 6000);
159
+ // auto perf log — first at 6s (past load hitches), then periodic.
160
+ //
161
+ // BOTH TIMERS ARE HELD, and start() clears whatever is already running. stop() used to
162
+ // clear only the interval — so stopping inside the first six seconds left the timeout to
163
+ // fire afterwards and install an interval nothing would ever clear, and calling start()
164
+ // twice stacked a timeout and an interval each time.
165
+ this._stopPerfTimers();
166
+ this._perfDelay = setTimeout(() => {
167
+ this._perfDelay = null;
168
+ this.perfReport();
169
+ this._perfTimer = setInterval(() => this.perfReport(), 15000);
170
+ }, 6000);
153
171
  }
154
172
  stop() {
155
173
  this.renderer.setAnimationLoop(null);
156
- if (this._perfTimer) clearInterval(this._perfTimer);
174
+ this._stopPerfTimers();
175
+ }
176
+ _stopPerfTimers() {
177
+ if (this._perfDelay) { clearTimeout(this._perfDelay); this._perfDelay = null; }
178
+ if (this._perfTimer) { clearInterval(this._perfTimer); this._perfTimer = null; }
157
179
  }
158
180
 
159
181
  _tick() {
@@ -196,7 +218,30 @@ export class Engine {
196
218
 
197
219
  // MAIN PHASE — the game's ordered hooks. Pause-gating of individual blocks is the
198
220
  // hook's business (ctx.paused); ordering between hooks is registration order.
199
- for (const fn of this._hooks.frame) fn(ctx);
221
+ //
222
+ // EACH HOOK IS ISOLATED. Unguarded, one hook that throws takes every hook registered
223
+ // AFTER it down with it, in silence — and the symptom surfaces wherever those systems
224
+ // were doing their work, which is nowhere near the throw. A game reported thrown grenades
225
+ // hanging in mid-air with frozen fuses; the cause was a footstep sound, four systems
226
+ // earlier. That is expensive to diagnose and cheap to prevent, and the timer loop just
227
+ // above has always done it this way.
228
+ //
229
+ // A hook that keeps throwing is retired rather than logged every frame at 60 Hz — the
230
+ // first stack trace is the useful one, and the thousandth buries it.
231
+ for (const fn of this._hooks.frame) {
232
+ if (this._hookFailed?.has(fn)) continue;
233
+ try {
234
+ fn(ctx);
235
+ } catch (e) {
236
+ (this._hookThrows ??= new Map()).set(fn, (this._hookThrows.get(fn) ?? 0) + 1);
237
+ const n = this._hookThrows.get(fn);
238
+ if (n === 1) console.error('[frame hook] threw — later hooks still ran:', e);
239
+ if (n >= HOOK_THROW_LIMIT) {
240
+ (this._hookFailed ??= new Set()).add(fn);
241
+ console.error(`[frame hook] threw ${n} times, retiring it. Fix the trace above.`);
242
+ }
243
+ }
244
+ }
200
245
 
201
246
  this.renderFrame();
202
247
  stamps.render = performance.now();
@@ -0,0 +1,84 @@
1
+ // DECLARED OVERRIDES — named decisions a game may answer for itself.
2
+ //
3
+ // The engine makes decisions internally. Most are right for every game; some are right only for
4
+ // the game the engine grew out of. When a game's content differs, it has had one option: reach
5
+ // in and patch the method that makes the decision. That works, and it is coupled to signatures,
6
+ // call order and private fields the game does not own, so it breaks silently on any minor. It
7
+ // does not compose either — if two games patch the same method for different reasons, both
8
+ // break and neither loudly.
9
+ //
10
+ // const engine = new Engine({
11
+ // …,
12
+ // overrides: {
13
+ // locomotionClip: ({ moving, aiming, reloading, has }) =>
14
+ // reloading && !has('gunReloadWalk') ? 'gunReload' : null, // null = engine decides
15
+ // },
16
+ // });
17
+ //
18
+ // FOUR PROPERTIES MAKE THIS WORTH MORE THAN FORMALISED MONKEY-PATCHING:
19
+ //
20
+ // 1. IT OVERRIDES DECISIONS, NOT METHODS. `override('Animator.play', fn)` would only legalise
21
+ // the fragility — `play`'s signature is implementation shape and will move. A decision is a
22
+ // QUESTION, and questions have stable contracts. Every design gap this exists to close is a
23
+ // question the engine currently answers with a hardcoded string.
24
+ //
25
+ // 2. THE DEFAULT IS TODAY'S BEHAVIOUR. Registering nothing is byte-identical. That is what
26
+ // makes it safe to add decision points to code that two shipped games depend on: returning
27
+ // `null` or `undefined` from an override also means "engine decides", so a handler that
28
+ // only cares about one case does not have to reimplement the rest.
29
+ //
30
+ // 3. NARROW AND DECLARED. Every point is listed in POINTS below with its signature. A game
31
+ // needing something that is not there has found the next one to add — which is a design
32
+ // conversation, not a silent patch.
33
+ //
34
+ // 4. IT FAILS AT REGISTRATION, NOT AT FRAME 900. An unknown name or a non-function is thrown
35
+ // at construction, where the stack trace points at the mistake.
36
+
37
+ // Every decision point the engine will ask about. `args` documents what the handler receives;
38
+ // `returns` what it may answer, where null/undefined always means "engine decides".
39
+ export const POINTS = {
40
+ locomotionClip: {
41
+ args: '{ moving, sprinting, aiming, reloading, holstering, drawn, weapon, animator, has }',
42
+ returns: 'a clip name to play, or null to let the engine choose',
43
+ why: 'western\'s gunslinger pack has a full-body clip for every gun state. Kubold\'s rifle '
44
+ + 'set has one standing reload and no walking reload, so a game on it needs the legs to '
45
+ + 'keep moving through a reload the engine would otherwise stop dead.',
46
+ },
47
+ };
48
+
49
+ export class Overrides {
50
+ constructor(map = {}) {
51
+ this._fns = new Map();
52
+ for (const [name, fn] of Object.entries(map ?? {})) {
53
+ if (!POINTS[name]) {
54
+ throw new Error(`[overrides] unknown decision point "${name}". Known: `
55
+ + `${Object.keys(POINTS).join(', ')}. A point that should exist and does not is a `
56
+ + 'design change, not a config typo.');
57
+ }
58
+ if (typeof fn !== 'function') {
59
+ throw new TypeError(`[overrides] "${name}" must be a function, got ${typeof fn}. `
60
+ + `It is asked: ${POINTS[name].args} -> ${POINTS[name].returns}`);
61
+ }
62
+ this._fns.set(name, fn);
63
+ }
64
+ }
65
+
66
+ has(name) { return this._fns.has(name); }
67
+
68
+ // Ask the game. Returns undefined when nothing is registered, when the handler declines by
69
+ // returning null/undefined, or when it throws — a broken override must not take the frame
70
+ // with it, and one bad answer per name is worth exactly one trace.
71
+ ask(name, args) {
72
+ const fn = this._fns.get(name);
73
+ if (!fn) return undefined;
74
+ try {
75
+ return fn(args) ?? undefined;
76
+ } catch (e) {
77
+ if (!(this._threw ??= new Set()).has(name)) {
78
+ this._threw.add(name);
79
+ console.error(`[overrides] "${name}" threw — the engine's own answer is being used:`, e);
80
+ }
81
+ return undefined;
82
+ }
83
+ }
84
+ }
@@ -95,7 +95,11 @@ export class AutoTuner {
95
95
  const fps = this.frames / this.acc;
96
96
  this.acc = 0; this.frames = 0;
97
97
  const order = ['high', 'medium', 'low'];
98
- const cur = order.indexOf(this.game.qualityName);
98
+ // WHAT ARE WE RUNNING? A game that never set `qualityName` gave indexOf(undefined) = -1,
99
+ // which passes the "can still step down" test and makes the next preset order[0] — so a
100
+ // bad framerate stepped quality UP, and paid applyQuality's setPixelRatio+setSize hitch to
101
+ // do it. Unknown means the top preset: that is what a game gets if it never chose.
102
+ const cur = Math.max(0, order.indexOf(this.game.qualityName ?? order[0]));
99
103
  if (fps < 24 && cur < order.length - 1) {
100
104
  // two consecutive bad windows before acting — applyQuality's setPixelRatio+setSize
101
105
  // is itself a big hitch, so a single loading burst must not trigger it mid-play
@@ -104,7 +108,7 @@ export class AutoTuner {
104
108
  this.badRun = 0;
105
109
  const next = order[cur + 1];
106
110
  applyQuality(this.game, next);
107
- this.game.ui?.toast(`Performance: graphics set to ${PRESETS[next].label} (change in Esc menu)`, 4200);
111
+ this.game.ui?.toast?.(`Performance: graphics set to ${PRESETS[next].label} (change in Esc menu)`, 4200);
108
112
  }
109
113
  } else if (fps >= 24) {
110
114
  this.done = true; // stable — stop fiddling
@@ -18,6 +18,9 @@ export function setClimbContent({ faceSign = null } = {}) { if (faceSign != null
18
18
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
19
19
  const _axis = new THREE.Vector3(), _face = new THREE.Vector3();
20
20
 
21
+ // how close the player must be to a ladder's base to mount it, in metres
22
+ const LADDER_REACH = 2.2;
23
+
21
24
  export class Climbing {
22
25
  constructor(player) {
23
26
  this.player = player;
@@ -138,11 +141,24 @@ export class Climbing {
138
141
  }
139
142
 
140
143
  // ---- fable-lineage conveniences (unioned): ladder scan + input-driven mount ----
141
- _ladders() { return this.game.world?.castle?.ladders || null; }
144
+ //
145
+ // WHEREVER THE GAME KEEPS THEM. This read `world.castle.ladders` alone — a fable dungeon
146
+ // path — while this file's own header says ladders come from `world.ladders`. Two of the
147
+ // three consumers therefore had no ladders at all, and nothing said so. Both are accepted;
148
+ // a game may also hand them over directly with `setLadders()`.
149
+ _ladders() {
150
+ return this._own ?? this.game.world?.ladders ?? this.game.world?.castle?.ladders ?? null;
151
+ }
152
+
153
+ // a game that keeps its ladders somewhere else entirely can just pass them in
154
+ setLadders(list) { this._own = list?.length ? list : null; }
142
155
 
143
156
  nearby() {
144
157
  const ls = this._ladders(); if (!ls || !ls.length) return null;
145
- const p = this.player.position; let best = null, bd = REACH * REACH;
158
+ // REACH was never declared anywhere in the package so this threw a ReferenceError the
159
+ // moment a non-empty ladder list existed, and tryMount() calls it unconditionally. The
160
+ // whole feature was unusable rather than merely misconfigured.
161
+ const p = this.player.position; let best = null, bd = LADDER_REACH * LADDER_REACH;
146
162
  for (const l of ls) {
147
163
  const dy = p.y - l.base.y;
148
164
  if (dy < -1.0 || dy > l.height + 1.0) continue;
@@ -490,7 +490,7 @@ class Coach {
490
490
  const people = []; // { x, z, ent } — ent null for the player (a coach does not shout the player aside)
491
491
  const p = this.game.player;
492
492
  const pp = p?.position ?? p?.root?.position;
493
- const mine = this.rider?.player === p || this.game.platforms?.carrier(p) === this;
493
+ const mine = this.rider?.player === p || this.game.platforms?.carrier?.(p) === this;
494
494
  if (pp && !mine) people.push({ x: pp.x, z: pp.z, ent: null });
495
495
  for (const n of this.game.npcs ?? []) {
496
496
  if (!n.alive) continue; // a coach drives over a body. It does not queue behind one
@@ -591,7 +591,7 @@ class Coach {
591
591
  board(player, kind) {
592
592
  // a man who sits down is no longer a man standing on the roof (and `aboard` writes his position
593
593
  // from the seat, so the two states must never both be live)
594
- this.game.platforms?.leave(player, 'aboard');
594
+ this.game.platforms?.leave?.(player, 'aboard');
595
595
  this.rider = { player, kind };
596
596
  // POINT THE CAMERA WHERE SHE IS GOING. It orbits the player at camYaw, and camYaw is whatever
597
597
  // you happened to be looking at when you climbed up — so you took the reins and found yourself
@@ -954,7 +954,7 @@ export class CoachLine {
954
954
  // A THING YOU CAN STAND ON. The registry measures her motion itself, so this is the whole
955
955
  // of it — no per-frame hook, nothing for the coach to remember, and the same four lines
956
956
  // will put a man on a boxcar. `cols` is what he stops being pushed by while he is up there.
957
- c.plat = this.game.platforms?.register({
957
+ c.plat = this.game.platforms?.register?.({
958
958
  root: c.root, owner: c, decks: [ROOF], hull: HULL,
959
959
  cols: c.myCols, radius: 4,
960
960
  });
@@ -130,7 +130,7 @@ export class Combat {
130
130
  p.dodgeT = 1.6; // i-frames — nothing interrupts an execution
131
131
  p.comboStep = 0; p.comboWindow = 0;
132
132
  p.attackCooldown = 0.3;
133
- this.game.audio?.play('swingHeavy');
133
+ this.game.audio?.play?.('swingHeavy');
134
134
  p.animator.play('punchUpper', {
135
135
  once: true, fade: 0.08, timeScale: 0.9, // slower = weightier than the combo upper
136
136
  onDone: () => { p.busy = false; p.dodgeT = Math.min(p.dodgeT, 0.1); },
@@ -150,8 +150,8 @@ export class Combat {
150
150
  this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 2);
151
151
  this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, 0.22); // the big crunch (kills are 0.13)
152
152
  this.game.shake(0.75);
153
- this.game.audio?.play('hit');
154
- this.game.ui?.showDamage(e, shown);
153
+ this.game.audio?.play?.('hit');
154
+ this.game.ui?.showDamage?.(e, shown);
155
155
  }
156
156
 
157
157
  // ONE fist-vs-body test. `e` is anything with the SHOOTABLE-PEOPLE contract — an outlaw, or a
@@ -177,7 +177,7 @@ export class Combat {
177
177
  this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 1);
178
178
  // hit-stop: a breath on connect, a longer one on the killing blow
179
179
  this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, !e.alive ? 0.13 : heavy ? 0.09 : 0.045);
180
- this.game.ui?.showDamage(e, Math.round(dmg));
180
+ this.game.ui?.showDamage?.(e, Math.round(dmg));
181
181
  return true;
182
182
  }
183
183
 
@@ -209,7 +209,7 @@ export class Combat {
209
209
  p.comboStep = (step + 1) % combo.length;
210
210
  p.comboWindow = 1.1;
211
211
  p.attackCooldown = heavy ? 0.25 : 0.12;
212
- this.game.audio?.play(heavy ? 'swingHeavy' : 'swing');
212
+ this.game.audio?.play?.(heavy ? 'swingHeavy' : 'swing');
213
213
 
214
214
  p.animator.play(anim, {
215
215
  once: true, fade: 0.07, timeScale: heavy ? 1.2 : 1.5,
@@ -242,12 +242,12 @@ export class Combat {
242
242
  if (this._meleeHit(n, dmg, heavy, range, arc)) hitAny = true;
243
243
  }
244
244
  // smashables share the swing arc — crates/barrels/bottles break on any hit
245
- if (this.game.smashables?.tryMelee(p.position, p.heading, range, arc)) hitAny = true;
245
+ if (this.game.smashables?.tryMelee?.(p.position, p.heading, range, arc)) hitAny = true;
246
246
  if (hitAny) {
247
- this.game.audio?.play('hit');
247
+ this.game.audio?.play?.('hit');
248
248
  this.game.shake(0.3);
249
249
  } else {
250
- this.game.audio?.play('whoosh');
250
+ this.game.audio?.play?.('whoosh');
251
251
  }
252
252
  });
253
253
  }
@@ -295,7 +295,7 @@ export class Combat {
295
295
  // bullet met the world: dirt/wood/stone all share the dust kick v1; the whine is rationed
296
296
  _worldImpact(pr) {
297
297
  this.game.vfx?.playFx?.('impact_dirt', { pos: pr.obj.position, scale: 1 });
298
- if (Math.random() < 0.3) this.game.audio?.play('ricochet');
298
+ if (Math.random() < 0.3) this.game.audio?.play?.('ricochet');
299
299
  }
300
300
 
301
301
  // ONE BULLET-VS-BODY TEST, for every kind of body there is: the outlaws (combat.enemies), the
@@ -328,23 +328,23 @@ export class Combat {
328
328
  if (hitY >= BODY.head) {
329
329
  const at = e.position.clone().setY(e.position.y + 1.62);
330
330
  e.takeDamage(e.maxHealth * 10, by.position, { knockback: 0.35, attacker: by });
331
- this.game.ui?.showDamage(e, 'HEADSHOT');
331
+ this.game.ui?.showDamage?.(e, 'HEADSHOT');
332
332
  this.game.vfx.hit(at);
333
333
  this.game.vfx.blood(at, pr.dir.clone().setY(pr.dir.y + 0.5), 2.6); // it makes a mess
334
- this.game.audio?.play('hit');
334
+ this.game.audio?.play?.('hit');
335
335
  this.game.shakeT = Math.max(this.game.shakeT ?? 0, 0.12);
336
336
  } else {
337
337
  e.takeDamage(pr.damage, by.position, { knockback: 0.15, attacker: by });
338
- this.game.ui?.showDamage(e, Math.round(pr.damage));
338
+ this.game.ui?.showDamage?.(e, Math.round(pr.damage));
339
339
  this.game.vfx.hit(e.position.clone().setY(e.position.y + 1.1));
340
340
  this._fleshImpact(pr, e);
341
- this.game.audio?.play('hit');
341
+ this.game.audio?.play?.('hit');
342
342
  }
343
343
  // THE RETICLE GOES RED — but only for the man holding the gun. An outlaw's round finding a
344
344
  // townsman is not YOUR hit and must not be reported as one; at 40m in bright desert the blood
345
345
  // spray is four pixels and the mark is the only honest answer to "did I connect?".
346
346
  if (by === this.game.player) {
347
- this.game.ui?.hitMark(!e.alive);
347
+ this.game.ui?.hitMark?.(!e.alive);
348
348
  // WHAT THE COUNTY KNOWS ABOUT YOU. Every man you put down is one the next one has heard about,
349
349
  // and the men they send after that are better shots (enemy.js, AIM). It is the only progression
350
350
  // this game keeps, and it is kept HERE because this is the one place a death by your hand is
@@ -470,7 +470,7 @@ export class Combat {
470
470
  // must not: `noEvict` bodies lie in the dirt where they fell, resident for life, costing one
471
471
  // distanceTo a frame past the cull. It is also better fiction — a cleared camp looks cleared.
472
472
  if (e.dead && !e.noEvict) {
473
- this.game.vatRoster?.release(e.vatProxy);
473
+ this.game.vatRoster?.release?.(e.vatProxy);
474
474
  this.game.scene.remove(e.root);
475
475
  e.spawner?.alive.splice(e.spawner.alive.indexOf(e), 1);
476
476
  this.enemies.splice(i, 1);
@@ -555,10 +555,10 @@ export class Combat {
555
555
  }
556
556
  }
557
557
  // bullets smash props they fly into (bottles on the saloon rail)
558
- if (!dead && this.game.smashables?.tryPoint(pr.obj.position, 0.75, pr.dir)) dead = true;
558
+ if (!dead && this.game.smashables?.tryPoint?.(pr.obj.position, 0.75, pr.dir)) dead = true;
559
559
  // THE COUNTY'S GAME. Deer and wolf are VAT herd instances, not entities — hunting.js owns
560
560
  // the sphere test, the death take, and the carcass the round leaves behind.
561
- if (!dead && this.game.hunting?.bulletHit(pr, _prevPos)) dead = true;
561
+ if (!dead && this.game.hunting?.bulletHit?.(pr, _prevPos)) dead = true;
562
562
  } else if (!dead && p.alive && pr.obj.position.distanceTo(tmpV1.copy(p.position).setY(p.position.y + 1.2)) < 0.9) {
563
563
  p.takeDamage(pr.damage, pr.shooter?.position ?? pr.obj.position);
564
564
  this._fleshImpact(pr, p);
@@ -228,7 +228,7 @@ export class Crime {
228
228
  if (lv === this.level) return;
229
229
  this.level = lv;
230
230
  if (lv > before) {
231
- this.game.audio?.play('aggro');
231
+ this.game.audio?.play?.('aggro');
232
232
  // the grace beat between "they know" and the first badge appearing — short at high stars
233
233
  this._dispatchT = lv >= 3 ? 1.2 : 2.5;
234
234
  const line = ['', 'A deputy is asking after you.', 'The sheriff has men on the street.',
@@ -567,13 +567,13 @@ export class Crime {
567
567
  // 5. REPORTING (combat.js's job, not main.js's). combat.js must call, from the friendly-bullet /
568
568
  // melee paths, when a CIVILIAN (townsfolk NPC or coach rider — anything with .civilian) is hit:
569
569
  //
570
- // this.game.crime?.report('assault', e.position); // hurt a civilian
571
- // this.game.crime?.report('murder', e.position); // killed a civilian
572
- // this.game.crime?.report('coachRobbery', e.position); // killed a coach driver/mate/fare
570
+ // this.game.crime?.report?.('assault', e.position); // hurt a civilian
571
+ // this.game.crime?.report?.('murder', e.position); // killed a civilian
572
+ // this.game.crime?.report?.('coachRobbery', e.position); // killed a coach driver/mate/fare
573
573
  //
574
574
  // and loot.js, when a corpse is robbed:
575
575
  //
576
- // game.crime?.report('robbery', corpse.position);
576
+ // game.crime?.report?.('robbery', corpse.position);
577
577
  //
578
578
  // Killing a LAWMAN needs NO call — Crime watches its own pool and reports it itself. (If
579
579
  // combat.js reports it anyway, a 0.6s de-dup window in report() swallows the double.)
@@ -96,7 +96,7 @@ export class DeadEye {
96
96
  if (!p.drawn && p._drawT <= 0) p._startDraw();
97
97
  }
98
98
  if (!this._canUse()) return false;
99
- if ((p.deadEye ?? 0) < DEAD_EYE.minCharge) { this.game.audio?.play('dryfire'); return false; }
99
+ if ((p.deadEye ?? 0) < DEAD_EYE.minCharge) { this.game.audio?.play?.('dryfire'); return false; }
100
100
  this.active = true;
101
101
  this._tagT = 0;
102
102
  this.tags.length = 0;
@@ -240,8 +240,8 @@ export class DeadEye {
240
240
  const cap = Math.min(DEAD_EYE.maxTags, p.ammo ?? 0);
241
241
  // A refused mark must SAY it was refused — a click that silently does nothing reads as a broken
242
242
  // button, and you will keep clicking it. Out of chambers and out of meter are the two caps.
243
- if (this.tags.length >= cap) { this.game.audio?.play('dryfire'); return; }
244
- if ((p.deadEye ?? 0) < DEAD_EYE.tagCost) { this.game.audio?.play('dryfire'); return; }
243
+ if (this.tags.length >= cap) { this.game.audio?.play?.('dryfire'); return; }
244
+ if ((p.deadEye ?? 0) < DEAD_EYE.tagCost) { this.game.audio?.play?.('dryfire'); return; }
245
245
  if (this._tagT > 0) return; // ...but a double-click inside 0.16s is just a hand
246
246
 
247
247
  _o.copy(this.game.camera.position);
@@ -274,7 +274,7 @@ export class DeadEye {
274
274
  });
275
275
  p.deadEye = clamp(p.deadEye - DEAD_EYE.tagCost, 0, DEAD_EYE.meterMax);
276
276
  this._tagT = DEAD_EYE.tagCooldown;
277
- this.game.audio?.play('ui');
277
+ this.game.audio?.play?.('ui');
278
278
  }
279
279
 
280
280
  // Closest approach of ray (o,dir — dir normalized) to segment [a,b]. Returns the ray
@@ -118,6 +118,6 @@ export class Encounters {
118
118
  gun.leashR = 40; // he will follow you a way for this — but not across the county
119
119
  }
120
120
  this.game.ui?.toast?.(`${grp.name} — they saw that.`, 3000);
121
- this.game.audio?.play('aggro');
121
+ this.game.audio?.play?.('aggro');
122
122
  }
123
123
  }