@rpgm-tools/neo-angband-core 0.13.0 → 0.14.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/README.md +1 -1
- package/dist/mod/hooks.d.ts +52 -12
- package/dist/mod/hooks.d.ts.map +1 -1
- package/dist/mod/hooks.js +39 -2
- package/dist/mod/hooks.js.map +1 -1
- package/dist/mod/save-blocks.d.ts +1 -1
- package/dist/mod/vocabulary.js +0 -0
- package/dist/mod/vocabulary.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/mod/hooks.ts +468 -402
- package/src/mod/vocabulary.ts +0 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ import { Rng, ENGINE_VERSION, PARITY_BASELINE } from "@rpgm-tools/neo-angband-co
|
|
|
32
32
|
const rng = new Rng(1234);
|
|
33
33
|
console.log(rng.damroll(3, 6), rng.damroll(3, 6), rng.damroll(3, 6)); // 9 13 8
|
|
34
34
|
|
|
35
|
-
console.log(ENGINE_VERSION, PARITY_BASELINE); // 0.
|
|
35
|
+
console.log(ENGINE_VERSION, PARITY_BASELINE); // 0.14.0 4.2.6
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Two entry points:
|
package/dist/mod/hooks.d.ts
CHANGED
|
@@ -184,27 +184,67 @@ export interface ModHooks {
|
|
|
184
184
|
/**
|
|
185
185
|
* Fold several mods' contributions into the single ModHooks core holds.
|
|
186
186
|
*
|
|
187
|
-
* Order is LOAD order, and
|
|
188
|
-
*
|
|
189
|
-
* and
|
|
187
|
+
* Order is LOAD order, and THE LATER MOD WINS EVERY DISAGREEMENT. That is the
|
|
188
|
+
* rule the mod manager's own row promises the player ("Move later (loads last,
|
|
189
|
+
* wins conflicts)") and the rule every other composition layer follows, so this
|
|
190
|
+
* one obeys it too - see the note below for the two folds that look like
|
|
191
|
+
* exceptions and are not.
|
|
190
192
|
*
|
|
193
|
+
* - LAST-HANDLER hooks (walkBlockedByDiggable) are asked in REVERSE load order
|
|
194
|
+
* and stop at the first non-null, so the last mod to have an opinion is the
|
|
195
|
+
* one whose handling takes effect and no earlier mod can double-spend the
|
|
196
|
+
* energy it already paid out.
|
|
197
|
+
* - ORDERING hooks (objectListTiebreak) chain the same way round: the last
|
|
198
|
+
* mod's comparator is the primary key and earlier ones break the ties it
|
|
199
|
+
* leaves, which is a valid total order and is "later wins" for a comparator.
|
|
200
|
+
* - TRANSFORM hooks (messageText) compose in load order, each seeing the
|
|
201
|
+
* previous one's output - so the last mod still speaks last and has the final
|
|
202
|
+
* say over the text that reaches the player.
|
|
191
203
|
* - VETO hooks (levelGenerated, artifactCommit, historyAdd) are conjunctive:
|
|
192
|
-
* every contributor
|
|
193
|
-
* only safe fold - a mod that vetoes a duplicate artifact must not be
|
|
194
|
-
* overruled by a later mod that merely has no opinion.
|
|
195
|
-
* - TRANSFORM hooks (messageText) compose in order, each seeing the previous
|
|
196
|
-
* one's output.
|
|
197
|
-
* - FIRST-HANDLER hooks (walkBlockedByDiggable) stop at the first non-null, so
|
|
198
|
-
* an earlier mod's handling wins and a later one cannot double-spend energy.
|
|
204
|
+
* every contributor runs and any refusal decides.
|
|
199
205
|
* - ANY hooks (saveNoiseScent) are disjunctive: one mod asking for the data is
|
|
200
206
|
* enough, because the data is additive and a second mod cannot object.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
207
|
+
*
|
|
208
|
+
* WHY THE LAST TWO ARE NOT EXCEPTIONS. "Later wins" answers the question "two
|
|
209
|
+
* mods disagree about one thing - whose answer is used?", and a veto hook is not
|
|
210
|
+
* asking that question. `true` from historyAdd means "I have nothing to say
|
|
211
|
+
* about this entry", not "I insist it be written": the hook is called once per
|
|
212
|
+
* entry, and two mods suppressing two different things are not in conflict at
|
|
213
|
+
* all. Resolving it last-wins would mean a later mod's silence cancelled an
|
|
214
|
+
* earlier mod's rule, breaking BOTH mods to satisfy a consistency nobody asked
|
|
215
|
+
* for. Same for saveNoiseScent, where `false` is "I do not need this" and the
|
|
216
|
+
* data is additive. The consistency that matters is that no mod's opinion is
|
|
217
|
+
* ever DISCARDED in favour of an earlier one, and these two discard nothing.
|
|
203
218
|
*
|
|
204
219
|
* Returns undefined when nothing contributed, so the caller can leave the field
|
|
205
220
|
* absent rather than storing an empty object - keeping "no mod loaded" and "a
|
|
206
221
|
* mod loaded that touches nothing" indistinguishable from core's side.
|
|
207
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* How composeModHooks folds several mods' contributions to one hook.
|
|
225
|
+
*
|
|
226
|
+
* A subset of the vocabulary the pack tooling uses for every composition layer
|
|
227
|
+
* (mod-sdk's `Fold`), so the host can hand these values straight to the conflict
|
|
228
|
+
* report and the compiler checks the two agree.
|
|
229
|
+
*/
|
|
230
|
+
export type ModHookFold = "all-must-agree" | "chained" | "last-answer" | "any-yes";
|
|
231
|
+
/**
|
|
232
|
+
* WHICH FOLD EACH HOOK USES, next to the function that implements it.
|
|
233
|
+
*
|
|
234
|
+
* The conflict report needs this to tell the player whether two mods touching
|
|
235
|
+
* one hook COMBINE or whether one of them is being silently ignored. Every fold
|
|
236
|
+
* here obeys "the later mod wins"; what differs is whether there is anything for
|
|
237
|
+
* a winner to win, and only `last-answer` leaves a contribution unrun. That
|
|
238
|
+
* distinction is the whole reason the report names the fold instead of printing
|
|
239
|
+
* one sentence about load order.
|
|
240
|
+
*
|
|
241
|
+
* It lives in core, beside composeModHooks, because a second copy in the host is
|
|
242
|
+
* the shape that drifts: the host is where the report is rendered, so a hook
|
|
243
|
+
* added here and forgotten there would be described by whatever the table
|
|
244
|
+
* happened to default to. Keyed by `keyof ModHooks`, so adding a member to the
|
|
245
|
+
* interface without adding it here does not compile.
|
|
246
|
+
*/
|
|
247
|
+
export declare const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>>;
|
|
208
248
|
/**
|
|
209
249
|
* A mod's hook function threw. Reported to whoever guarded it (guardModHooks).
|
|
210
250
|
*
|
package/dist/mod/hooks.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB;;;;;;;;;;;;;;;;OAgBG;IACH,qBAAqB,CAAC,EAAE,CACtB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,EAC9B,IAAI,EAAE,OAAO,KACV,MAAM,GAAG,IAAI,CAAC;IAEnB;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,CACnB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC/C,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAC5C,MAAM,CAAC;IAEZ;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3D;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,KAAK,OAAO,CAAC;IAEpE;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC;IAE/G;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAE/B;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB;;;;;;;;;;;;;;;;OAgBG;IACH,qBAAqB,CAAC,EAAE,CACtB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,EAC9B,IAAI,EAAE,OAAO,KACV,MAAM,GAAG,IAAI,CAAC;IAEnB;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,CACnB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC/C,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAC5C,MAAM,CAAC;IAEZ;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3D;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,KAAK,OAAO,CAAC;IAEpE;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC;IAE/G;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAE/B;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GACnB,gBAAgB,GAChB,SAAS,GACT,aAAa,GACb,SAAS,CAAC;AAEd;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,QAAQ,EAAE,WAAW,CAAC,CAQxE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,QAAQ,CAAC;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GACrC,QAAQ,CAmEV;AAED,wBAAgB,eAAe,CAC7B,aAAa,EAAE,SAAS,QAAQ,EAAE,GACjC,QAAQ,GAAG,SAAS,CA8EtB"}
|
package/dist/mod/hooks.js
CHANGED
|
@@ -69,6 +69,31 @@
|
|
|
69
69
|
* break by reaching for a global; the test suite pins it by running generation
|
|
70
70
|
* with a hook installed and asserting the level is bit-identical.
|
|
71
71
|
*/
|
|
72
|
+
/**
|
|
73
|
+
* WHICH FOLD EACH HOOK USES, next to the function that implements it.
|
|
74
|
+
*
|
|
75
|
+
* The conflict report needs this to tell the player whether two mods touching
|
|
76
|
+
* one hook COMBINE or whether one of them is being silently ignored. Every fold
|
|
77
|
+
* here obeys "the later mod wins"; what differs is whether there is anything for
|
|
78
|
+
* a winner to win, and only `last-answer` leaves a contribution unrun. That
|
|
79
|
+
* distinction is the whole reason the report names the fold instead of printing
|
|
80
|
+
* one sentence about load order.
|
|
81
|
+
*
|
|
82
|
+
* It lives in core, beside composeModHooks, because a second copy in the host is
|
|
83
|
+
* the shape that drifts: the host is where the report is rendered, so a hook
|
|
84
|
+
* added here and forgotten there would be described by whatever the table
|
|
85
|
+
* happened to default to. Keyed by `keyof ModHooks`, so adding a member to the
|
|
86
|
+
* interface without adding it here does not compile.
|
|
87
|
+
*/
|
|
88
|
+
export const MOD_HOOK_FOLDS = {
|
|
89
|
+
walkBlockedByDiggable: "last-answer",
|
|
90
|
+
objectListTiebreak: "last-answer",
|
|
91
|
+
levelGenerated: "all-must-agree",
|
|
92
|
+
artifactCommit: "all-must-agree",
|
|
93
|
+
historyAdd: "all-must-agree",
|
|
94
|
+
saveNoiseScent: "any-yes",
|
|
95
|
+
messageText: "chained",
|
|
96
|
+
};
|
|
72
97
|
/**
|
|
73
98
|
* ONE mod's contribution, with every hook wrapped so a throw cannot escape into
|
|
74
99
|
* the middle of a turn.
|
|
@@ -164,9 +189,18 @@ export function composeModHooks(contributions) {
|
|
|
164
189
|
if (list.length === 0)
|
|
165
190
|
return undefined;
|
|
166
191
|
const out = {};
|
|
167
|
-
|
|
192
|
+
/* REVERSE load order for both of the last-answer folds, so the mod the player
|
|
193
|
+
* moved to the bottom of the list is the one that gets asked first and
|
|
194
|
+
* therefore the one that decides. Reversed here, once, rather than by walking
|
|
195
|
+
* the array backwards at call time: the reversal is a property of the FOLD, and
|
|
196
|
+
* a `for (let i = n - 1; ...)` in the hot path is the kind of detail a later
|
|
197
|
+
* edit quietly straightens out. */
|
|
198
|
+
const walk = list.map((c) => c.walkBlockedByDiggable).filter(isFn).reverse();
|
|
168
199
|
if (walk.length > 0) {
|
|
169
200
|
out.walkBlockedByDiggable = (state, grid, deps) => {
|
|
201
|
+
/* Declining is contractually free of observable effect (see the member's
|
|
202
|
+
* doc), so asking a mod that then declines costs nothing - which is what
|
|
203
|
+
* makes it safe to ask them in any order at all. */
|
|
170
204
|
for (const fn of walk) {
|
|
171
205
|
const energy = fn(state, grid, deps);
|
|
172
206
|
if (energy !== null)
|
|
@@ -175,9 +209,12 @@ export function composeModHooks(contributions) {
|
|
|
175
209
|
return null;
|
|
176
210
|
};
|
|
177
211
|
}
|
|
178
|
-
const tiebreak = list.map((c) => c.objectListTiebreak).filter(isFn);
|
|
212
|
+
const tiebreak = list.map((c) => c.objectListTiebreak).filter(isFn).reverse();
|
|
179
213
|
if (tiebreak.length > 0) {
|
|
180
214
|
out.objectListTiebreak = (a, b) => {
|
|
215
|
+
/* The last mod's comparator is the primary sort key and the earlier ones
|
|
216
|
+
* only break the ties it leaves equal - a lexicographic chain, still a
|
|
217
|
+
* consistent total order, and "the later mod wins" for a comparator. */
|
|
181
218
|
for (const fn of tiebreak) {
|
|
182
219
|
const r = fn(a, b);
|
|
183
220
|
if (r !== 0)
|
package/dist/mod/hooks.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;
|
|
1
|
+
{"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"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"}
|
|
@@ -128,7 +128,7 @@ export type OrphanStore = Record<string, OrphanEntry[]>;
|
|
|
128
128
|
* core pack outright, and versionMap's versions are only ever used to key
|
|
129
129
|
* quarantined MOD entities. An existing save keeps whatever it recorded.
|
|
130
130
|
*/
|
|
131
|
-
export declare const CORE_PACK_VERSION = "0.
|
|
131
|
+
export declare const CORE_PACK_VERSION = "0.14.0";
|
|
132
132
|
/** A core-only manifest: the base game with no mods, deterministic. */
|
|
133
133
|
export declare function coreOnlyManifest(): SaveManifest;
|
|
134
134
|
/**
|
package/dist/mod/vocabulary.js
CHANGED
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vocabulary.js","sourceRoot":"","sources":["../../src/mod/vocabulary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA+BH,0GAA0G;AAC1G,SAAS,OAAO,CAAC,IAAe,EAAE,IAAY;IAC5C,OAAO,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"vocabulary.js","sourceRoot":"","sources":["../../src/mod/vocabulary.ts"],"names":[],"mappings":"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"}
|
package/dist/version.d.ts
CHANGED
|
@@ -21,5 +21,5 @@ export declare const PARITY_BASELINE = "4.2.6";
|
|
|
21
21
|
* released tag is iterated takes a MINOR bump, because a published tag is pinned
|
|
22
22
|
* by digest in a catalogue and must never be moved.
|
|
23
23
|
*/
|
|
24
|
-
export declare const ENGINE_VERSION = "0.
|
|
24
|
+
export declare const ENGINE_VERSION = "0.14.0";
|
|
25
25
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -21,5 +21,5 @@ export const PARITY_BASELINE = "4.2.6";
|
|
|
21
21
|
* released tag is iterated takes a MINOR bump, because a published tag is pinned
|
|
22
22
|
* by digest in a catalogue and must never be moved.
|
|
23
23
|
*/
|
|
24
|
-
export const ENGINE_VERSION = "0.
|
|
24
|
+
export const ENGINE_VERSION = "0.14.0";
|
|
25
25
|
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
package/src/mod/hooks.ts
CHANGED
|
@@ -1,402 +1,468 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The behaviour seam: the closed set of points where a mod may change what the
|
|
3
|
-
* ENGINE does, as opposed to what content it runs on.
|
|
4
|
-
*
|
|
5
|
-
* WHY THIS EXISTS
|
|
6
|
-
*
|
|
7
|
-
* Content mods are already fully expressible - a pack contributes records and
|
|
8
|
-
* composeContentPacks merges them, and core never learns a mod was involved.
|
|
9
|
-
* Behaviour is different. A fix like "don't log a duplicate unique kill" is not a
|
|
10
|
-
* record; it is a decision taken inside a function, and no amount of data can
|
|
11
|
-
* reach inside that function from outside. So the first cut of the bundled
|
|
12
|
-
* bug-fixes / QoL mods did the only thing that was available: the FIX LIVED IN
|
|
13
|
-
* CORE behind `if (modRuleEnabled(state, "bugfix.x"))`.
|
|
14
|
-
*
|
|
15
|
-
* That was wrong, and neostryder named exactly why (2026-07-29): "the whole point of
|
|
16
|
-
* making them mods was to exclude them from the core game." A flag-gated fix is
|
|
17
|
-
* not excluded from core. It ships in core, it is tested in core, and core
|
|
18
|
-
* carries the string "bugfix.uniqueKillHistory" - so core knows the mod's name.
|
|
19
|
-
* Deleting the mod folder would not delete one line of it.
|
|
20
|
-
*
|
|
21
|
-
* WHAT THIS CHANGES, AND WHAT IT HONESTLY DOES NOT
|
|
22
|
-
*
|
|
23
|
-
* After this, core contains no mod's fix and no mod's name: no `bugfix.*` or
|
|
24
|
-
* `qol.*` literal, no staircase repair, no message rewriter. Each fix body lives
|
|
25
|
-
* in its mod's own folder and arrives as that mod's code.
|
|
26
|
-
*
|
|
27
|
-
* What core still contains - and cannot stop containing - is the SEAM ITSELF:
|
|
28
|
-
* one named, documented extension point per behaviour a mod may override. That
|
|
29
|
-
* is not a residue of the bundled mods; it is the price of behaviour modding at
|
|
30
|
-
* all, and it is the same bargain SKSE, Forge and every other real modding
|
|
31
|
-
* system strikes. The difference from a flag read is the difference that matters:
|
|
32
|
-
* a seam is generic and available to ANY mod, and it holds no opinion about what
|
|
33
|
-
* should plug into it, whereas `if (modRuleEnabled(state, "bugfix.stairsReachable"))`
|
|
34
|
-
* is core implementing one specific mod.
|
|
35
|
-
*
|
|
36
|
-
* SHAPE
|
|
37
|
-
*
|
|
38
|
-
* A plain interface of optional functions rather than a string-keyed registry,
|
|
39
|
-
* for three reasons:
|
|
40
|
-
*
|
|
41
|
-
* - It is typed end to end. A registry keyed by strings pushes every payload
|
|
42
|
-
* through `unknown` and moves the errors to runtime.
|
|
43
|
-
* - "A disabled mod's patches DO NOT EXIST" becomes literally true, not merely
|
|
44
|
-
* false-y: with no mod loaded the field is ABSENT, the optional call is not
|
|
45
|
-
* made, and the faithful path is the only path that was ever compiled into
|
|
46
|
-
* the branch. No flag map is consulted because there is no flag map.
|
|
47
|
-
* - It costs nothing. `state.modHooks?.historyAdd?.(...)` on an absent object is
|
|
48
|
-
* one undefined check, not a map lookup per call.
|
|
49
|
-
*
|
|
50
|
-
* COMPOSITION IS THE HOST'S JOB
|
|
51
|
-
*
|
|
52
|
-
* Two enabled mods may both want the same hook. Core deliberately holds ONE
|
|
53
|
-
* ModHooks and knows nothing about mod identity, ordering or enablement - the
|
|
54
|
-
* host (web/cli) collects each enabled mod's contributions in load order and
|
|
55
|
-
* folds them into a single object (see composeModHooks). This is the same
|
|
56
|
-
* layering as content: core consumes a composed result and never the pack list.
|
|
57
|
-
*
|
|
58
|
-
* A hook is arbitrary third-party code running inside a turn, so it can throw.
|
|
59
|
-
* guardModHooks wraps one mod's contribution and turns a throw into that hook's
|
|
60
|
-
* neutral answer - see its comment for why that is the least-bad of the three
|
|
61
|
-
* options, and for the half of the job it deliberately leaves to the host.
|
|
62
|
-
*
|
|
63
|
-
* DETERMINISM
|
|
64
|
-
*
|
|
65
|
-
* Two of these run inside the generation and object pipelines, where an extra
|
|
66
|
-
* RNG draw does not merely change a value, it desynchronises every draw after
|
|
67
|
-
* it - so a seed would stop meaning the same level. Those hooks are documented
|
|
68
|
-
* RNG-FREE and are given no rng to draw from. That is a contract a mod can still
|
|
69
|
-
* break by reaching for a global; the test suite pins it by running generation
|
|
70
|
-
* with a hook installed and asserting the level is bit-identical.
|
|
71
|
-
*/
|
|
72
|
-
|
|
73
|
-
import type { GameState } from "../game/context.js";
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A mod's behaviour contributions. Every member is optional; an absent member
|
|
77
|
-
* means "this mod does not touch that point" and core takes its faithful path.
|
|
78
|
-
*
|
|
79
|
-
* Each entry documents the ONE core call site it serves, because a hook whose
|
|
80
|
-
* call site is not written down is a hook nobody can verify is still wired -
|
|
81
|
-
* exactly the failure the call-site census exists to catch.
|
|
82
|
-
*/
|
|
83
|
-
export interface ModHooks {
|
|
84
|
-
/**
|
|
85
|
-
* A walk into a grid the player could tunnel through (cave-cmd.ts,
|
|
86
|
-
* movementAutoDig's call site).
|
|
87
|
-
*
|
|
88
|
-
* Return the energy the move should cost, or null to decline - and declining
|
|
89
|
-
* MUST be free of observable effect, because faithful core's behaviour here is
|
|
90
|
-
* to bump into the wall without drawing any RNG. A hook that rolls a dig check
|
|
91
|
-
* and then returns null has already moved the RNG stream.
|
|
92
|
-
*
|
|
93
|
-
* `deps` is the live CaveCmdDeps the session built, passed through because the
|
|
94
|
-
* work a mod would want to do here (tunnelAux - one real dig attempt with the
|
|
95
|
-
* upstream roll and payouts) needs dependencies only the session can construct.
|
|
96
|
-
* Typed as unknown so this seam does not drag the cave-command types into every
|
|
97
|
-
* consumer of ModHooks; a mod casts it back to CaveCmdDeps, which core exports.
|
|
98
|
-
*
|
|
99
|
-
* Serves: the QoL mod's "auto-dig on walk".
|
|
100
|
-
*/
|
|
101
|
-
walkBlockedByDiggable?: (
|
|
102
|
-
state: GameState,
|
|
103
|
-
grid: { y: number; x: number },
|
|
104
|
-
deps: unknown,
|
|
105
|
-
) => number | null;
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Two floor-list entries that compared exactly equal, including on distance
|
|
109
|
-
* (obj-list.ts, the comparator's final tiebreak).
|
|
110
|
-
*
|
|
111
|
-
* Return a negative/positive number to order them, or 0 to leave them equal
|
|
112
|
-
* (which is what faithful core does - it keeps collect order via a stable
|
|
113
|
-
* sort). Must be a consistent total order or the sort is meaningless.
|
|
114
|
-
*
|
|
115
|
-
* Serves: the bug-fixes mod's strict object-list ordering (#4664).
|
|
116
|
-
*/
|
|
117
|
-
objectListTiebreak?: (
|
|
118
|
-
a: { readonly dy: number; readonly dx: number },
|
|
119
|
-
b: { readonly dy: number; readonly dx: number },
|
|
120
|
-
) => number;
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* A finished, otherwise-accepted level, before cave_generate returns it
|
|
124
|
-
* (generate.ts, the accept branch).
|
|
125
|
-
*
|
|
126
|
-
* Return false to REJECT the level, which makes cave_generate re-roll exactly
|
|
127
|
-
* as it does for a monster-maximum overflow. May mutate the level to repair it
|
|
128
|
-
* and then return true.
|
|
129
|
-
*
|
|
130
|
-
* RNG-FREE: no rng is passed and none may be reached. A draw here shifts every
|
|
131
|
-
* subsequent draw and a seed stops reproducing its dungeon.
|
|
132
|
-
*
|
|
133
|
-
* Serves: the bug-fixes mod's reachable-staircase repair.
|
|
134
|
-
*/
|
|
135
|
-
levelGenerated?: (gen: unknown, quest: boolean) => boolean;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* An object about to be committed as an artifact, when it ALREADY carries one
|
|
139
|
-
* (obj/make.ts, the `obj.artifact` branch).
|
|
140
|
-
*
|
|
141
|
-
* Return false to refuse the commit; the caller then clears the artifact and
|
|
142
|
-
* reports failure. Faithful core commits it unconditionally.
|
|
143
|
-
*
|
|
144
|
-
* RNG-FREE, for the same reason as levelGenerated: this runs inside object
|
|
145
|
-
* creation, on the main stream.
|
|
146
|
-
*
|
|
147
|
-
* Serves: the bug-fixes mod's duplicate-artifact guard (#4510).
|
|
148
|
-
*/
|
|
149
|
-
artifactCommit?: (aidx: number, alreadyCreated: boolean) => boolean;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A character-history entry about to be written (session/game.ts and any
|
|
153
|
-
* other historyAdd call site that passes its context).
|
|
154
|
-
*
|
|
155
|
-
* Return false to suppress the entry. Faithful core writes every entry it
|
|
156
|
-
* reaches, duplicates included.
|
|
157
|
-
*
|
|
158
|
-
* Serves: the bug-fixes mod's no-duplicate-unique-kill fix (#4245).
|
|
159
|
-
*/
|
|
160
|
-
historyAdd?: (entry: { readonly what: string; readonly type: number; readonly duplicate: boolean }) => boolean;
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Whether the noise and scent heatmaps belong in the save (session/save.ts,
|
|
164
|
-
* snapshotSquares' argument).
|
|
165
|
-
*
|
|
166
|
-
* Return true to persist them. Faithful core omits them, which is the upstream
|
|
167
|
-
* behaviour and the upstream bug.
|
|
168
|
-
*
|
|
169
|
-
* Serves: the bug-fixes mod's noise/scent persistence (#4605).
|
|
170
|
-
*/
|
|
171
|
-
saveNoiseScent?: () => boolean;
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Player-visible message text, on its way to the message line (the host's
|
|
175
|
-
* message sink).
|
|
176
|
-
*
|
|
177
|
-
* Return the text to show. Faithful core shows what it was given, warts and
|
|
178
|
-
* all. A hook here can only RESTATE a message - it must never change what a
|
|
179
|
-
* message means, or the port would be showing text upstream never wrote and no
|
|
180
|
-
* census could see it.
|
|
181
|
-
*
|
|
182
|
-
* Serves: the bug-fixes mod's cosmetic string corrections.
|
|
183
|
-
*/
|
|
184
|
-
messageText?: (raw: string) => string;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Fold several mods' contributions into the single ModHooks core holds.
|
|
189
|
-
*
|
|
190
|
-
* Order is LOAD order, and
|
|
191
|
-
*
|
|
192
|
-
* and
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* -
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
out.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour seam: the closed set of points where a mod may change what the
|
|
3
|
+
* ENGINE does, as opposed to what content it runs on.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
*
|
|
7
|
+
* Content mods are already fully expressible - a pack contributes records and
|
|
8
|
+
* composeContentPacks merges them, and core never learns a mod was involved.
|
|
9
|
+
* Behaviour is different. A fix like "don't log a duplicate unique kill" is not a
|
|
10
|
+
* record; it is a decision taken inside a function, and no amount of data can
|
|
11
|
+
* reach inside that function from outside. So the first cut of the bundled
|
|
12
|
+
* bug-fixes / QoL mods did the only thing that was available: the FIX LIVED IN
|
|
13
|
+
* CORE behind `if (modRuleEnabled(state, "bugfix.x"))`.
|
|
14
|
+
*
|
|
15
|
+
* That was wrong, and neostryder named exactly why (2026-07-29): "the whole point of
|
|
16
|
+
* making them mods was to exclude them from the core game." A flag-gated fix is
|
|
17
|
+
* not excluded from core. It ships in core, it is tested in core, and core
|
|
18
|
+
* carries the string "bugfix.uniqueKillHistory" - so core knows the mod's name.
|
|
19
|
+
* Deleting the mod folder would not delete one line of it.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS CHANGES, AND WHAT IT HONESTLY DOES NOT
|
|
22
|
+
*
|
|
23
|
+
* After this, core contains no mod's fix and no mod's name: no `bugfix.*` or
|
|
24
|
+
* `qol.*` literal, no staircase repair, no message rewriter. Each fix body lives
|
|
25
|
+
* in its mod's own folder and arrives as that mod's code.
|
|
26
|
+
*
|
|
27
|
+
* What core still contains - and cannot stop containing - is the SEAM ITSELF:
|
|
28
|
+
* one named, documented extension point per behaviour a mod may override. That
|
|
29
|
+
* is not a residue of the bundled mods; it is the price of behaviour modding at
|
|
30
|
+
* all, and it is the same bargain SKSE, Forge and every other real modding
|
|
31
|
+
* system strikes. The difference from a flag read is the difference that matters:
|
|
32
|
+
* a seam is generic and available to ANY mod, and it holds no opinion about what
|
|
33
|
+
* should plug into it, whereas `if (modRuleEnabled(state, "bugfix.stairsReachable"))`
|
|
34
|
+
* is core implementing one specific mod.
|
|
35
|
+
*
|
|
36
|
+
* SHAPE
|
|
37
|
+
*
|
|
38
|
+
* A plain interface of optional functions rather than a string-keyed registry,
|
|
39
|
+
* for three reasons:
|
|
40
|
+
*
|
|
41
|
+
* - It is typed end to end. A registry keyed by strings pushes every payload
|
|
42
|
+
* through `unknown` and moves the errors to runtime.
|
|
43
|
+
* - "A disabled mod's patches DO NOT EXIST" becomes literally true, not merely
|
|
44
|
+
* false-y: with no mod loaded the field is ABSENT, the optional call is not
|
|
45
|
+
* made, and the faithful path is the only path that was ever compiled into
|
|
46
|
+
* the branch. No flag map is consulted because there is no flag map.
|
|
47
|
+
* - It costs nothing. `state.modHooks?.historyAdd?.(...)` on an absent object is
|
|
48
|
+
* one undefined check, not a map lookup per call.
|
|
49
|
+
*
|
|
50
|
+
* COMPOSITION IS THE HOST'S JOB
|
|
51
|
+
*
|
|
52
|
+
* Two enabled mods may both want the same hook. Core deliberately holds ONE
|
|
53
|
+
* ModHooks and knows nothing about mod identity, ordering or enablement - the
|
|
54
|
+
* host (web/cli) collects each enabled mod's contributions in load order and
|
|
55
|
+
* folds them into a single object (see composeModHooks). This is the same
|
|
56
|
+
* layering as content: core consumes a composed result and never the pack list.
|
|
57
|
+
*
|
|
58
|
+
* A hook is arbitrary third-party code running inside a turn, so it can throw.
|
|
59
|
+
* guardModHooks wraps one mod's contribution and turns a throw into that hook's
|
|
60
|
+
* neutral answer - see its comment for why that is the least-bad of the three
|
|
61
|
+
* options, and for the half of the job it deliberately leaves to the host.
|
|
62
|
+
*
|
|
63
|
+
* DETERMINISM
|
|
64
|
+
*
|
|
65
|
+
* Two of these run inside the generation and object pipelines, where an extra
|
|
66
|
+
* RNG draw does not merely change a value, it desynchronises every draw after
|
|
67
|
+
* it - so a seed would stop meaning the same level. Those hooks are documented
|
|
68
|
+
* RNG-FREE and are given no rng to draw from. That is a contract a mod can still
|
|
69
|
+
* break by reaching for a global; the test suite pins it by running generation
|
|
70
|
+
* with a hook installed and asserting the level is bit-identical.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import type { GameState } from "../game/context.js";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A mod's behaviour contributions. Every member is optional; an absent member
|
|
77
|
+
* means "this mod does not touch that point" and core takes its faithful path.
|
|
78
|
+
*
|
|
79
|
+
* Each entry documents the ONE core call site it serves, because a hook whose
|
|
80
|
+
* call site is not written down is a hook nobody can verify is still wired -
|
|
81
|
+
* exactly the failure the call-site census exists to catch.
|
|
82
|
+
*/
|
|
83
|
+
export interface ModHooks {
|
|
84
|
+
/**
|
|
85
|
+
* A walk into a grid the player could tunnel through (cave-cmd.ts,
|
|
86
|
+
* movementAutoDig's call site).
|
|
87
|
+
*
|
|
88
|
+
* Return the energy the move should cost, or null to decline - and declining
|
|
89
|
+
* MUST be free of observable effect, because faithful core's behaviour here is
|
|
90
|
+
* to bump into the wall without drawing any RNG. A hook that rolls a dig check
|
|
91
|
+
* and then returns null has already moved the RNG stream.
|
|
92
|
+
*
|
|
93
|
+
* `deps` is the live CaveCmdDeps the session built, passed through because the
|
|
94
|
+
* work a mod would want to do here (tunnelAux - one real dig attempt with the
|
|
95
|
+
* upstream roll and payouts) needs dependencies only the session can construct.
|
|
96
|
+
* Typed as unknown so this seam does not drag the cave-command types into every
|
|
97
|
+
* consumer of ModHooks; a mod casts it back to CaveCmdDeps, which core exports.
|
|
98
|
+
*
|
|
99
|
+
* Serves: the QoL mod's "auto-dig on walk".
|
|
100
|
+
*/
|
|
101
|
+
walkBlockedByDiggable?: (
|
|
102
|
+
state: GameState,
|
|
103
|
+
grid: { y: number; x: number },
|
|
104
|
+
deps: unknown,
|
|
105
|
+
) => number | null;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Two floor-list entries that compared exactly equal, including on distance
|
|
109
|
+
* (obj-list.ts, the comparator's final tiebreak).
|
|
110
|
+
*
|
|
111
|
+
* Return a negative/positive number to order them, or 0 to leave them equal
|
|
112
|
+
* (which is what faithful core does - it keeps collect order via a stable
|
|
113
|
+
* sort). Must be a consistent total order or the sort is meaningless.
|
|
114
|
+
*
|
|
115
|
+
* Serves: the bug-fixes mod's strict object-list ordering (#4664).
|
|
116
|
+
*/
|
|
117
|
+
objectListTiebreak?: (
|
|
118
|
+
a: { readonly dy: number; readonly dx: number },
|
|
119
|
+
b: { readonly dy: number; readonly dx: number },
|
|
120
|
+
) => number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A finished, otherwise-accepted level, before cave_generate returns it
|
|
124
|
+
* (generate.ts, the accept branch).
|
|
125
|
+
*
|
|
126
|
+
* Return false to REJECT the level, which makes cave_generate re-roll exactly
|
|
127
|
+
* as it does for a monster-maximum overflow. May mutate the level to repair it
|
|
128
|
+
* and then return true.
|
|
129
|
+
*
|
|
130
|
+
* RNG-FREE: no rng is passed and none may be reached. A draw here shifts every
|
|
131
|
+
* subsequent draw and a seed stops reproducing its dungeon.
|
|
132
|
+
*
|
|
133
|
+
* Serves: the bug-fixes mod's reachable-staircase repair.
|
|
134
|
+
*/
|
|
135
|
+
levelGenerated?: (gen: unknown, quest: boolean) => boolean;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An object about to be committed as an artifact, when it ALREADY carries one
|
|
139
|
+
* (obj/make.ts, the `obj.artifact` branch).
|
|
140
|
+
*
|
|
141
|
+
* Return false to refuse the commit; the caller then clears the artifact and
|
|
142
|
+
* reports failure. Faithful core commits it unconditionally.
|
|
143
|
+
*
|
|
144
|
+
* RNG-FREE, for the same reason as levelGenerated: this runs inside object
|
|
145
|
+
* creation, on the main stream.
|
|
146
|
+
*
|
|
147
|
+
* Serves: the bug-fixes mod's duplicate-artifact guard (#4510).
|
|
148
|
+
*/
|
|
149
|
+
artifactCommit?: (aidx: number, alreadyCreated: boolean) => boolean;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A character-history entry about to be written (session/game.ts and any
|
|
153
|
+
* other historyAdd call site that passes its context).
|
|
154
|
+
*
|
|
155
|
+
* Return false to suppress the entry. Faithful core writes every entry it
|
|
156
|
+
* reaches, duplicates included.
|
|
157
|
+
*
|
|
158
|
+
* Serves: the bug-fixes mod's no-duplicate-unique-kill fix (#4245).
|
|
159
|
+
*/
|
|
160
|
+
historyAdd?: (entry: { readonly what: string; readonly type: number; readonly duplicate: boolean }) => boolean;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the noise and scent heatmaps belong in the save (session/save.ts,
|
|
164
|
+
* snapshotSquares' argument).
|
|
165
|
+
*
|
|
166
|
+
* Return true to persist them. Faithful core omits them, which is the upstream
|
|
167
|
+
* behaviour and the upstream bug.
|
|
168
|
+
*
|
|
169
|
+
* Serves: the bug-fixes mod's noise/scent persistence (#4605).
|
|
170
|
+
*/
|
|
171
|
+
saveNoiseScent?: () => boolean;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Player-visible message text, on its way to the message line (the host's
|
|
175
|
+
* message sink).
|
|
176
|
+
*
|
|
177
|
+
* Return the text to show. Faithful core shows what it was given, warts and
|
|
178
|
+
* all. A hook here can only RESTATE a message - it must never change what a
|
|
179
|
+
* message means, or the port would be showing text upstream never wrote and no
|
|
180
|
+
* census could see it.
|
|
181
|
+
*
|
|
182
|
+
* Serves: the bug-fixes mod's cosmetic string corrections.
|
|
183
|
+
*/
|
|
184
|
+
messageText?: (raw: string) => string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Fold several mods' contributions into the single ModHooks core holds.
|
|
189
|
+
*
|
|
190
|
+
* Order is LOAD order, and THE LATER MOD WINS EVERY DISAGREEMENT. That is the
|
|
191
|
+
* rule the mod manager's own row promises the player ("Move later (loads last,
|
|
192
|
+
* wins conflicts)") and the rule every other composition layer follows, so this
|
|
193
|
+
* one obeys it too - see the note below for the two folds that look like
|
|
194
|
+
* exceptions and are not.
|
|
195
|
+
*
|
|
196
|
+
* - LAST-HANDLER hooks (walkBlockedByDiggable) are asked in REVERSE load order
|
|
197
|
+
* and stop at the first non-null, so the last mod to have an opinion is the
|
|
198
|
+
* one whose handling takes effect and no earlier mod can double-spend the
|
|
199
|
+
* energy it already paid out.
|
|
200
|
+
* - ORDERING hooks (objectListTiebreak) chain the same way round: the last
|
|
201
|
+
* mod's comparator is the primary key and earlier ones break the ties it
|
|
202
|
+
* leaves, which is a valid total order and is "later wins" for a comparator.
|
|
203
|
+
* - TRANSFORM hooks (messageText) compose in load order, each seeing the
|
|
204
|
+
* previous one's output - so the last mod still speaks last and has the final
|
|
205
|
+
* say over the text that reaches the player.
|
|
206
|
+
* - VETO hooks (levelGenerated, artifactCommit, historyAdd) are conjunctive:
|
|
207
|
+
* every contributor runs and any refusal decides.
|
|
208
|
+
* - ANY hooks (saveNoiseScent) are disjunctive: one mod asking for the data is
|
|
209
|
+
* enough, because the data is additive and a second mod cannot object.
|
|
210
|
+
*
|
|
211
|
+
* WHY THE LAST TWO ARE NOT EXCEPTIONS. "Later wins" answers the question "two
|
|
212
|
+
* mods disagree about one thing - whose answer is used?", and a veto hook is not
|
|
213
|
+
* asking that question. `true` from historyAdd means "I have nothing to say
|
|
214
|
+
* about this entry", not "I insist it be written": the hook is called once per
|
|
215
|
+
* entry, and two mods suppressing two different things are not in conflict at
|
|
216
|
+
* all. Resolving it last-wins would mean a later mod's silence cancelled an
|
|
217
|
+
* earlier mod's rule, breaking BOTH mods to satisfy a consistency nobody asked
|
|
218
|
+
* for. Same for saveNoiseScent, where `false` is "I do not need this" and the
|
|
219
|
+
* data is additive. The consistency that matters is that no mod's opinion is
|
|
220
|
+
* ever DISCARDED in favour of an earlier one, and these two discard nothing.
|
|
221
|
+
*
|
|
222
|
+
* Returns undefined when nothing contributed, so the caller can leave the field
|
|
223
|
+
* absent rather than storing an empty object - keeping "no mod loaded" and "a
|
|
224
|
+
* mod loaded that touches nothing" indistinguishable from core's side.
|
|
225
|
+
*/
|
|
226
|
+
/**
|
|
227
|
+
* How composeModHooks folds several mods' contributions to one hook.
|
|
228
|
+
*
|
|
229
|
+
* A subset of the vocabulary the pack tooling uses for every composition layer
|
|
230
|
+
* (mod-sdk's `Fold`), so the host can hand these values straight to the conflict
|
|
231
|
+
* report and the compiler checks the two agree.
|
|
232
|
+
*/
|
|
233
|
+
export type ModHookFold =
|
|
234
|
+
| "all-must-agree" // every contributor runs; the first refusal decides
|
|
235
|
+
| "chained" // each sees the previous one's output, so the last one speaks last
|
|
236
|
+
| "last-answer" // the LAST contributor with an opinion decides; earlier ones are not asked
|
|
237
|
+
| "any-yes"; // one contributor asking for it is enough
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* WHICH FOLD EACH HOOK USES, next to the function that implements it.
|
|
241
|
+
*
|
|
242
|
+
* The conflict report needs this to tell the player whether two mods touching
|
|
243
|
+
* one hook COMBINE or whether one of them is being silently ignored. Every fold
|
|
244
|
+
* here obeys "the later mod wins"; what differs is whether there is anything for
|
|
245
|
+
* a winner to win, and only `last-answer` leaves a contribution unrun. That
|
|
246
|
+
* distinction is the whole reason the report names the fold instead of printing
|
|
247
|
+
* one sentence about load order.
|
|
248
|
+
*
|
|
249
|
+
* It lives in core, beside composeModHooks, because a second copy in the host is
|
|
250
|
+
* the shape that drifts: the host is where the report is rendered, so a hook
|
|
251
|
+
* added here and forgotten there would be described by whatever the table
|
|
252
|
+
* happened to default to. Keyed by `keyof ModHooks`, so adding a member to the
|
|
253
|
+
* interface without adding it here does not compile.
|
|
254
|
+
*/
|
|
255
|
+
export const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>> = {
|
|
256
|
+
walkBlockedByDiggable: "last-answer",
|
|
257
|
+
objectListTiebreak: "last-answer",
|
|
258
|
+
levelGenerated: "all-must-agree",
|
|
259
|
+
artifactCommit: "all-must-agree",
|
|
260
|
+
historyAdd: "all-must-agree",
|
|
261
|
+
saveNoiseScent: "any-yes",
|
|
262
|
+
messageText: "chained",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A mod's hook function threw. Reported to whoever guarded it (guardModHooks).
|
|
267
|
+
*
|
|
268
|
+
* Carries no mod id, because core does not know one - the host wraps ONE mod's
|
|
269
|
+
* contribution at a time and already holds the id in the closure it passes in.
|
|
270
|
+
*/
|
|
271
|
+
export interface ModHookFault {
|
|
272
|
+
/** Which extension point threw, by its ModHooks member name. */
|
|
273
|
+
readonly hook: keyof ModHooks;
|
|
274
|
+
/** Whatever the mod threw, unwrapped and uninterpreted. */
|
|
275
|
+
readonly error: unknown;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* ONE mod's contribution, with every hook wrapped so a throw cannot escape into
|
|
280
|
+
* the middle of a turn.
|
|
281
|
+
*
|
|
282
|
+
* WHY THIS IS NOT "CATCH AND CARRY ON REGARDLESS". An uncaught throw here lands
|
|
283
|
+
* in the middle of core - halfway through a move, a generation pass or an object
|
|
284
|
+
* roll - and unwinds the rest of the turn. It does not undo what the mod already
|
|
285
|
+
* did before it threw, so the state is no LESS inconsistent for the abort; it is
|
|
286
|
+
* more, because the turn's remaining bookkeeping never ran. And it reaches the
|
|
287
|
+
* host as a bare exception from a function the host did not know a mod was
|
|
288
|
+
* inside, so the player gets a frozen screen and no name.
|
|
289
|
+
*
|
|
290
|
+
* So a throwing hook is treated as a mod that DECLINED to answer at that point,
|
|
291
|
+
* and the neutral answer is per-hook - it has to be, because "no opinion" is
|
|
292
|
+
* `true` for a veto, `null` for a handler, `0` for a comparator and the input
|
|
293
|
+
* itself for a transform. Those values live HERE, next to the fold rule in
|
|
294
|
+
* composeModHooks that already spells out each hook's kind, rather than in the
|
|
295
|
+
* host: two copies of "what does this hook mean by nothing" is the pair that
|
|
296
|
+
* drifts.
|
|
297
|
+
*
|
|
298
|
+
* WHAT THIS DOES NOT DO, deliberately: it does not make the session safe. The
|
|
299
|
+
* mod ran arbitrary code against live state and stopped partway. Neutralising
|
|
300
|
+
* the hook keeps the game on its feet long enough to say so; it is the HOST's
|
|
301
|
+
* job to treat the fault as terminal for the session - stop writing saves and
|
|
302
|
+
* tell the player to reload (packages/web/src/mod-taint.ts). A guard without
|
|
303
|
+
* that second half would be the worst outcome of the three: quietly wrong.
|
|
304
|
+
*
|
|
305
|
+
* LATCHED PER HOOK. Once a hook has thrown it is not called again for the rest
|
|
306
|
+
* of the session. A hook that throws on a bad monster will throw on the next
|
|
307
|
+
* one too, and an exception per call turns one fault into a storm of them; more
|
|
308
|
+
* importantly, "sometimes the mod's rule applies and sometimes it doesn't" is
|
|
309
|
+
* harder to reason about than "it stopped applying at the point it broke". The
|
|
310
|
+
* mod's other hooks are untouched - the latch is per (mod, hook), not per mod.
|
|
311
|
+
*/
|
|
312
|
+
export function guardModHooks(
|
|
313
|
+
hooks: ModHooks,
|
|
314
|
+
onFault: (fault: ModHookFault) => void,
|
|
315
|
+
): ModHooks {
|
|
316
|
+
const out: ModHooks = {};
|
|
317
|
+
const stopped = new Set<keyof ModHooks>();
|
|
318
|
+
|
|
319
|
+
function guard<T>(hook: keyof ModHooks, run: () => T, neutral: T): T {
|
|
320
|
+
if (stopped.has(hook)) return neutral;
|
|
321
|
+
try {
|
|
322
|
+
return run();
|
|
323
|
+
} catch (error) {
|
|
324
|
+
stopped.add(hook);
|
|
325
|
+
onFault({ hook, error });
|
|
326
|
+
return neutral;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const walk = hooks.walkBlockedByDiggable;
|
|
331
|
+
if (walk) {
|
|
332
|
+
/* null is DECLINE, so core bumps the wall as it would with no mod loaded. */
|
|
333
|
+
out.walkBlockedByDiggable = (state, grid, deps): number | null =>
|
|
334
|
+
guard("walkBlockedByDiggable", () => walk(state, grid, deps), null);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const tiebreak = hooks.objectListTiebreak;
|
|
338
|
+
if (tiebreak) {
|
|
339
|
+
/* 0 is "still equal", which is exactly faithful core's answer. */
|
|
340
|
+
out.objectListTiebreak = (a, b): number =>
|
|
341
|
+
guard("objectListTiebreak", () => tiebreak(a, b), 0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const level = hooks.levelGenerated;
|
|
345
|
+
if (level) {
|
|
346
|
+
/* ACCEPT. Rejecting on a throw would re-roll the level, and cave_generate
|
|
347
|
+
* would then re-roll it again on the next throw, and so on until it gives
|
|
348
|
+
* up - one broken hook would make the game unable to reach any level. */
|
|
349
|
+
out.levelGenerated = (gen, quest): boolean =>
|
|
350
|
+
guard("levelGenerated", () => level(gen, quest), true);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const artifact = hooks.artifactCommit;
|
|
354
|
+
if (artifact) {
|
|
355
|
+
/* COMMIT, which is what core does unconditionally without the hook. */
|
|
356
|
+
out.artifactCommit = (aidx, alreadyCreated): boolean =>
|
|
357
|
+
guard("artifactCommit", () => artifact(aidx, alreadyCreated), true);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const history = hooks.historyAdd;
|
|
361
|
+
if (history) {
|
|
362
|
+
/* WRITE the entry: faithful core writes every entry it reaches. Suppressing
|
|
363
|
+
* on a throw would delete history the player earned. */
|
|
364
|
+
out.historyAdd = (entry): boolean =>
|
|
365
|
+
guard("historyAdd", () => history(entry), true);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const noise = hooks.saveNoiseScent;
|
|
369
|
+
if (noise) {
|
|
370
|
+
/* OMIT them, the upstream behaviour. */
|
|
371
|
+
out.saveNoiseScent = (): boolean => guard("saveNoiseScent", () => noise(), false);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const text = hooks.messageText;
|
|
375
|
+
if (text) {
|
|
376
|
+
/* The raw message, unrestated - never an empty string, which would silently
|
|
377
|
+
* eat a message the player needed to read. */
|
|
378
|
+
out.messageText = (raw): string => guard("messageText", () => text(raw), raw);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function composeModHooks(
|
|
385
|
+
contributions: readonly ModHooks[],
|
|
386
|
+
): ModHooks | undefined {
|
|
387
|
+
const list = contributions.filter((c) => Object.keys(c).length > 0);
|
|
388
|
+
if (list.length === 0) return undefined;
|
|
389
|
+
|
|
390
|
+
const out: ModHooks = {};
|
|
391
|
+
|
|
392
|
+
/* REVERSE load order for both of the last-answer folds, so the mod the player
|
|
393
|
+
* moved to the bottom of the list is the one that gets asked first and
|
|
394
|
+
* therefore the one that decides. Reversed here, once, rather than by walking
|
|
395
|
+
* the array backwards at call time: the reversal is a property of the FOLD, and
|
|
396
|
+
* a `for (let i = n - 1; ...)` in the hot path is the kind of detail a later
|
|
397
|
+
* edit quietly straightens out. */
|
|
398
|
+
const walk = list.map((c) => c.walkBlockedByDiggable).filter(isFn).reverse();
|
|
399
|
+
if (walk.length > 0) {
|
|
400
|
+
out.walkBlockedByDiggable = (state, grid, deps): number | null => {
|
|
401
|
+
/* Declining is contractually free of observable effect (see the member's
|
|
402
|
+
* doc), so asking a mod that then declines costs nothing - which is what
|
|
403
|
+
* makes it safe to ask them in any order at all. */
|
|
404
|
+
for (const fn of walk) {
|
|
405
|
+
const energy = fn(state, grid, deps);
|
|
406
|
+
if (energy !== null) return energy;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const tiebreak = list.map((c) => c.objectListTiebreak).filter(isFn).reverse();
|
|
413
|
+
if (tiebreak.length > 0) {
|
|
414
|
+
out.objectListTiebreak = (a, b): number => {
|
|
415
|
+
/* The last mod's comparator is the primary sort key and the earlier ones
|
|
416
|
+
* only break the ties it leaves equal - a lexicographic chain, still a
|
|
417
|
+
* consistent total order, and "the later mod wins" for a comparator. */
|
|
418
|
+
for (const fn of tiebreak) {
|
|
419
|
+
const r = fn(a, b);
|
|
420
|
+
if (r !== 0) return r;
|
|
421
|
+
}
|
|
422
|
+
return 0;
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const level = list.map((c) => c.levelGenerated).filter(isFn);
|
|
427
|
+
if (level.length > 0) {
|
|
428
|
+
out.levelGenerated = (gen, quest): boolean => {
|
|
429
|
+
/* Every contributor runs even after one has repaired the level, because a
|
|
430
|
+
* second mod's invariant is not satisfied by the first mod's repair. The
|
|
431
|
+
* first REFUSAL short-circuits, since the level is being thrown away. */
|
|
432
|
+
for (const fn of level) if (!fn(gen, quest)) return false;
|
|
433
|
+
return true;
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const artifact = list.map((c) => c.artifactCommit).filter(isFn);
|
|
438
|
+
if (artifact.length > 0) {
|
|
439
|
+
out.artifactCommit = (aidx, alreadyCreated): boolean => {
|
|
440
|
+
for (const fn of artifact) if (!fn(aidx, alreadyCreated)) return false;
|
|
441
|
+
return true;
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const history = list.map((c) => c.historyAdd).filter(isFn);
|
|
446
|
+
if (history.length > 0) {
|
|
447
|
+
out.historyAdd = (entry): boolean => {
|
|
448
|
+
for (const fn of history) if (!fn(entry)) return false;
|
|
449
|
+
return true;
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const noise = list.map((c) => c.saveNoiseScent).filter(isFn);
|
|
454
|
+
if (noise.length > 0) {
|
|
455
|
+
out.saveNoiseScent = (): boolean => noise.some((fn) => fn());
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const text = list.map((c) => c.messageText).filter(isFn);
|
|
459
|
+
if (text.length > 0) {
|
|
460
|
+
out.messageText = (raw): string => text.reduce((s, fn) => fn(s), raw);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isFn<T>(v: T | undefined): v is T {
|
|
467
|
+
return typeof v === "function";
|
|
468
|
+
}
|
package/src/mod/vocabulary.ts
CHANGED
|
Binary file
|
package/src/version.ts
CHANGED
|
@@ -23,4 +23,4 @@ export const PARITY_BASELINE = "4.2.6";
|
|
|
23
23
|
* released tag is iterated takes a MINOR bump, because a published tag is pinned
|
|
24
24
|
* by digest in a catalogue and must never be moved.
|
|
25
25
|
*/
|
|
26
|
-
export const ENGINE_VERSION = "0.
|
|
26
|
+
export const ENGINE_VERSION = "0.14.0";
|