sindicate 0.16.0 → 0.18.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,121 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 — two regressions, caught from outside
4
+
5
+ Everything here came from a third game running against 0.17.0 and reporting back. One fix I
6
+ shipped broken, one fix I shipped incomplete, and both were found by a consumer rather than by
7
+ me. Nothing else changed, and western and fable do not move.
8
+
9
+ ### Fixed
10
+
11
+ - **`RETRIGGER_AT` was undeclared** (`core/anim.js`), so 0.17.0's own one-shot guard threw a
12
+ `ReferenceError` on the exact path it was written to protect — re-triggering a one-shot that is
13
+ still playing. Western was affected too: a revolver re-triggering its fire clip does that on
14
+ every second shot. Frame-hook isolation caught it rather than killing the frame, which is the
15
+ only reason it was not immediately fatal, but the one-shot silently did not play.
16
+ - **A split answer's overlay ran as an infinite loop.** `setOverlay` defaults to
17
+ `LoopRepeat/Infinity`, and the `{ base, overlay }` shape did not say otherwise, so a reload
18
+ overlay played for ever. The answer is now `{ base, overlay, once }`. It cannot be inferred: a
19
+ reload or holster overlay is a one-shot, a held aim pose must loop, and both are legitimate
20
+ answers to the same question.
21
+
22
+ ### Changed
23
+
24
+ - `locomotionClip` may answer `{ base, overlay, once }` as well as a clip name — a body split is
25
+ two simultaneous answers and one name cannot express both — and the engine now asks BEFORE
26
+ dropping the overlay rather than after. Dropping it first rewound the overlay sixty times a
27
+ second, which a game could only stop by patching `setOverlay`. A string answer behaves exactly
28
+ as in 0.17.0; `null`/`undefined` still mean the engine owns the slot.
29
+
30
+ ### The lesson, recorded because it caused both
31
+
32
+ Both regressions survived because I verified the wrong layer. `node --check` passes on an
33
+ undeclared identifier — it is a runtime `ReferenceError`, not a syntax error — and a test that
34
+ watches what was *asked for* is blind to what the ask *did*. The tests now drive the real
35
+ per-frame sequence and watch the animation across more than one clip length. An edit anchored on
36
+ `import * as THREE from 'three';` also silently matches nothing in a file that imports from
37
+ `'three/webgpu'`, which is how the constant went missing in the first place.
38
+
39
+ ## 0.17.0 — towns, and a third game's report card
40
+
41
+ Two halves. Cities — the thing the road system was always for. And the first pass through
42
+ `ISSUES.md`, raised from building a **third** game (MilitaryGame/"Blacksand", a military game on
43
+ Synty + Kubold + Kevin Iglesias packs) against 0.16.0 as a published dependency rather than a
44
+ file link. Every fix in that half defaults to exactly the previous behaviour, so western and
45
+ fable do not move.
46
+
47
+ ### Cities (new: `world/cityPlan.js`, `world/settlement.js`, `world/mapWorld.js`)
48
+
49
+ - `world/cityPlan.js` — a settlement as a street grid whose faces are BLOCKS, zoned. Terrain is a
50
+ veto: water and slope reject a cell outright, population decides how many of the survivors get
51
+ built on, and the town grows outward from its middle so it is one place rather than a rash of
52
+ hamlets. Junctions are classified by arm count — and two arms is **not one case**: two opposite
53
+ arms are a street running through, two perpendicular are a corner.
54
+ - `world/settlement.js` — `planBlockFrontage` fills the blocks, which is the step Kiwi's own
55
+ `BlockData` marks "for building placement later" and never took. Buildings stand on the block
56
+ boundary facing the street; a model's odds decay as it is used, so a pack of sixteen renders as
57
+ sixteen rather than five. `hasFrontage` refuses motorways and dual carriageways outright —
58
+ nothing in the world fronts one.
59
+ - `world/mapWorld.js` — several places and the roads between them. A city exposes `exits`, shaped
60
+ like a road-kit socket, so `buildRoadLink` joins town to motorway with no special case.
61
+ - `world/roadLink.js` — extrudes a **cross-section with height in it** when a socket carries one:
62
+ kerb, gutter and raised footway are in the profile, not laid separately. Four surfaces on their
63
+ own texels, and the past-the-kerb rule that decides which is which.
64
+ - `world/roadTerrain.js` — a corridor now declares whether it carries tarmac (`deck: false` for
65
+ pure earthwork), and coverage stops at the ends of a line instead of claiming a half-disc of
66
+ phantom road beyond them.
67
+
68
+ **The lesson that cost the most, recorded because it will recur:** the earthworks were tuned by
69
+ eye against a height FUNCTION, while every fault lived in the drawn LATTICE. Between two vertices
70
+ straddling a cut the mesh interpolates straight across, so 12 cm of clearance vanishes on a 3.8 m
71
+ cell. Kiwi's own figures — a 1.0 m road bed and a 5 m skirt — are an order of magnitude bigger
72
+ than the ones guessed here for exactly that reason.
73
+
74
+ ### Reported by a third game (`ISSUES.md`, all 14 items)
75
+
76
+ - **A guarded object is not a guarded method.** `game.audio?.loadSamples(map)` guards `audio`
77
+ alone; 59 call sites across 13 files had the same shape. And frame hooks ran unguarded, so one
78
+ throw silently killed every system registered after it — reported as grenades hanging in
79
+ mid-air with frozen fuses, four systems from a footstep sound. Hooks are now isolated, and a
80
+ hook that throws eight times is retired rather than logged at 60 Hz.
81
+ - **Masks follow their source clip.** A game re-registering a logical clip (a weapon switch) kept
82
+ masks pointing at the boot weapon for ever. And a one-shot re-triggered faster than it plays is
83
+ no longer restarted — 200 restarts over 19 s becomes 34.
84
+ - **`climbing.js` referenced an undeclared `REACH`**, so its proximity-mount path threw on sight;
85
+ it also read only fable's `world.castle.ladders`. Both fixed, plus `setLadders()`.
86
+ - **`createCar()` spawned from the default ride height** while every later frame used the
87
+ configured one — a vehicle at 0.9 started 0.56 m low and popped up.
88
+ - Perf timers are both held and cleared; an unset `qualityName` no longer answers a BAD framerate
89
+ by stepping quality **up**.
90
+ - Dead `setTrainContent` keys say so; an unknown walk-hump is scenery rather than a crash, with
91
+ `setHumpDims()` to teach it.
92
+
93
+ ### Declared overrides (new: `core/overrides.js`)
94
+
95
+ Named decisions a game may answer for itself, registered at construction and never patched at
96
+ runtime. It overrides **decisions, not methods** — `play`'s signature is implementation shape and
97
+ will move; "what should the body be playing?" is a question with a stable contract. Registering
98
+ nothing, or answering `null`, is byte-identical to before. Unknown names and non-functions throw
99
+ at construction; an override that throws at runtime is caught and the engine's own answer used.
100
+
101
+ First point: `locomotionClip`. The full-body clip per gun state is right for western's gunslinger
102
+ pack and wrong for Kubold's rifle set, which has one standing reload — so walking into a reload
103
+ stopped the legs dead.
104
+
105
+ ### Also injected rather than assumed
106
+
107
+ - `AudioSystem` takes an `sfxManifest`; western's list is exported as `DUSTWATER_SFX(dirs)`.
108
+ - Seated-pose assets take `setSeatedContent({ rig, clips, audioDir })` — a soldier in a tank hatch
109
+ wants the same retarget as a rider on a horse.
110
+ - The weapon table derives from what the game registered. Only three lines ever named western's
111
+ weapons, and they were what made the constructor throw without both `revolver` and `rifle`.
112
+
113
+ ### Notes
114
+
115
+ - True N-slot weapons, the seat/occupant abstraction, and `city` support in `roadAssert` are
116
+ deliberately **not** in this release. Each is scheduled rather than landed opportunistically.
117
+ - The city modules are new and may still move before 1.0.
118
+
3
119
  ## 0.16.0 — driving on it
4
120
 
5
121
  ### 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.18.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
@@ -2,6 +2,11 @@
2
2
  // with automatic retargeting per character rig.
3
3
  import * as THREE from 'three/webgpu';
4
4
  import { buildRetargetContext, retargetClip } from './retarget.js';
5
+
6
+ // How far a one-shot must have played before another play() will restart it, as a fraction of
7
+ // its duration. Below this the call is ignored and the clip runs on — see play(). A caller may
8
+ // override per call with `retrigger`.
9
+ const RETRIGGER_AT = 0.5;
5
10
  // Per-clip timing (start/speed/end trims) and the clip->source-rig table are GAME
6
11
  // DATA, not engine code — a game registers them once at boot:
7
12
  // setClipDefaults({ timing: CLIP_TIMING, rigs: CLIP_RIGS })
@@ -57,12 +62,24 @@ export class Animator {
57
62
  const ctx = this._ctxFor(this.clipRigs?.[name] ?? null);
58
63
  const action = this.mixer.clipAction(retargetClip(clip, ctx));
59
64
  this.actions[name] = action;
65
+ // A MASK IS DERIVED FROM A CLIP, so re-registering the clip must rebuild it. Masks were
66
+ // snapshotted at build time and never invalidated, so a game that re-registers a logical
67
+ // name later — one that swaps weapons by re-registering `gunFire`, say — kept masks
68
+ // pointing at whatever was loaded at boot. Every weapon then fired and reloaded like the
69
+ // one you spawned with, silently and for ever. The engine created the derivations, so it
70
+ // is the engine's job to know them.
71
+ for (const d of this._derived?.get(name) ?? []) this.registerMasked(d.name, name, d.keepFn);
60
72
  }
61
73
 
62
74
  // Build a bone-masked copy of an already-registered clip (keeps only tracks whose
63
75
  // bone passes keepFn). Used to split locomotion across body halves — e.g. legs
64
76
  // from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
65
77
  registerMasked(name, srcName, keepFn) {
78
+ // remembered so register() can rebuild it — see the note there
79
+ const list = (this._derived ??= new Map()).get(srcName) ?? [];
80
+ if (!list.some((d) => d.name === name)) list.push({ name, keepFn });
81
+ this._derived.set(srcName, list);
82
+
66
83
  const src = this.actions[srcName];
67
84
  if (!src) return;
68
85
  const clip = src.getClip().clone();
@@ -125,6 +142,16 @@ export class Animator {
125
142
  const timeScale = opts.timeScale ?? tm.speed ?? 1;
126
143
  if (this.currentName === name && !restart && !once) return action;
127
144
 
145
+ // A ONE-SHOT RE-TRIGGERED FASTER THAN IT PLAYS IS NOT A ONE-SHOT. One-shots deliberately
146
+ // skip the early return above so each call restarts them, which is right for a revolver
147
+ // and wrong for an M4: at a 0.095 s rate a 1 s full-body fire clip restarts ten times a
148
+ // second and the whole body jitters. Let it run unless it has advanced far enough to be
149
+ // worth restarting, or the caller insists with `restart`.
150
+ if (once && !restart && this.current === action && action.isRunning()) {
151
+ const dur = action.getClip().duration || 0;
152
+ if (dur > 0 && action.time < dur * (opts.retrigger ?? RETRIGGER_AT)) return action;
153
+ }
154
+
128
155
  action.reset();
129
156
  if (startFrac > 0) action.time = action.getClip().duration * startFrac;
130
157
  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,88 @@
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, or { base, overlay, once } when the body is split, or null to let '
43
+ + 'the engine choose. A split needs TWO simultaneous answers — a legs-only base and '
44
+ + 'an upper-body overlay — which a single name cannot express. `once` says whether '
45
+ + 'that overlay is a one-shot (a reload, a holster) or a held pose that loops (an '
46
+ + 'aim): it cannot be inferred, and both are legitimate answers to this question.',
47
+ why: 'western\'s gunslinger pack has a full-body clip for every gun state. Kubold\'s rifle '
48
+ + 'set has one standing reload and no walking reload, so a game on it needs the legs to '
49
+ + 'keep moving through a reload the engine would otherwise stop dead.',
50
+ },
51
+ };
52
+
53
+ export class Overrides {
54
+ constructor(map = {}) {
55
+ this._fns = new Map();
56
+ for (const [name, fn] of Object.entries(map ?? {})) {
57
+ if (!POINTS[name]) {
58
+ throw new Error(`[overrides] unknown decision point "${name}". Known: `
59
+ + `${Object.keys(POINTS).join(', ')}. A point that should exist and does not is a `
60
+ + 'design change, not a config typo.');
61
+ }
62
+ if (typeof fn !== 'function') {
63
+ throw new TypeError(`[overrides] "${name}" must be a function, got ${typeof fn}. `
64
+ + `It is asked: ${POINTS[name].args} -> ${POINTS[name].returns}`);
65
+ }
66
+ this._fns.set(name, fn);
67
+ }
68
+ }
69
+
70
+ has(name) { return this._fns.has(name); }
71
+
72
+ // Ask the game. Returns undefined when nothing is registered, when the handler declines by
73
+ // returning null/undefined, or when it throws — a broken override must not take the frame
74
+ // with it, and one bad answer per name is worth exactly one trace.
75
+ ask(name, args) {
76
+ const fn = this._fns.get(name);
77
+ if (!fn) return undefined;
78
+ try {
79
+ return fn(args) ?? undefined;
80
+ } catch (e) {
81
+ if (!(this._threw ??= new Set()).has(name)) {
82
+ this._threw.add(name);
83
+ console.error(`[overrides] "${name}" threw — the engine's own answer is being used:`, e);
84
+ }
85
+ return undefined;
86
+ }
87
+ }
88
+ }
@@ -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.)