@rpgm-tools/neo-angband-mod-sdk 1.1.0 → 1.1.2

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.
@@ -0,0 +1,785 @@
1
+ # Mod Lifecycle, Saves, and Composition
2
+
3
+ > STATUS: RATIFIED 2026-07-08 (PORT_PLAN.md decision 19). Decisions 1, 2, 3, 5,
4
+ > and 6 are confirmed as written, and 4 (the determinism guard) stands with the
5
+ > change recorded in section 4 below: it is a warning and label, not a bar. The
6
+ > uninstall-recovery behaviors in the new section "When a mod's content leaves
7
+ > the game" are ratified alongside them. This page is the design of record; it
8
+ > is not yet fully built. [OPEN] items still need a decision.
9
+
10
+ This page answers four questions that decide whether a mod system is
11
+ pleasant or painful:
12
+
13
+ 1. How is mod content kept out of the way so installing, updating, and
14
+ uninstalling mods does not break your save?
15
+ 2. How do you install a mod - from a git repo today, from a marketplace
16
+ later - without friction?
17
+ 3. How do several mods run together without corrupting each other?
18
+ 4. What makes the whole thing ergonomic instead of the usual mod-manager
19
+ headache?
20
+
21
+ It builds on the vocabulary already in `README.md` (packs, `manifest.json`,
22
+ namespaced ids, `patches`/`replaces`/`removes`, provenance) and the
23
+ ratified pillars in `../MODS.md`.
24
+
25
+ ---
26
+
27
+ ## 1. Saves that survive mod changes
28
+
29
+ The single most important rule, from which almost everything else
30
+ follows:
31
+
32
+ > [PROPOSED] Saves reference content by stable namespaced string id,
33
+ > never by numeric index.
34
+
35
+ Upstream Angband serializes a monster as its `r_idx` and an item as its
36
+ `k_idx` - array positions. Add or remove one record and every later
37
+ index shifts, silently corrupting old saves. That fragility is exactly
38
+ what breaks modded saves elsewhere. This port serializes `core:kobold`,
39
+ `frost:frost-wyrm`, `mypack:quest-of-the-lost-ring` and resolves the
40
+ string to a runtime index at load time. Adding, removing, or reordering
41
+ content never moves an existing id.
42
+
43
+ ### The save is block-structured and namespaced
44
+
45
+ The savefile is already block-based (a faithful port of `savefile.c`:
46
+ magic, then framed blocks). The port extends it into three tiers:
47
+
48
+ - A `manifest` block: the exact mod set that produced this save - each
49
+ pack's `id`, `version`, content hash, and source (git URL + ref, or
50
+ marketplace id), plus the resolved load order. This is the save's
51
+ "profile fingerprint".
52
+ - Core blocks (player, dungeon, messages, RNG state, ...): the base
53
+ game state. All cross-references inside use namespaced string ids.
54
+ - One block per mod, keyed `mod:<id>`: that mod's own private state - an
55
+ opaque bag the engine never interprets, versioned by the mod's
56
+ `saveSchema` number. A scripted plugin persists whatever it likes here
57
+ and is the only thing that reads it back.
58
+
59
+ Every persisted entity that came from a mod records the id of its
60
+ definition. A `frost:frost-wyrm` standing on the level is stored as a
61
+ normal monster instance whose "race" reference is the string
62
+ `frost:frost-wyrm`. The instance data is core-shaped; only the reference
63
+ is mod-owned.
64
+
65
+ ### What happens when the mod set changes under a save
66
+
67
+ Because each mod owns exactly its own namespace, the blast radius of any
68
+ change is that namespace:
69
+
70
+ - Mod ADDED since the save: its namespace was empty in the save; it
71
+ simply starts contributing. No migration.
72
+ - Mod UPDATED (version changed): the engine hands the mod its own old
73
+ `mod:<id>` bag and asks it to migrate from its old `saveSchema` to the
74
+ new one. Declarative content usually needs nothing (ids are stable);
75
+ scripted mods ship a migration function that touches only their bag.
76
+ Core never participates.
77
+ - Mod REMOVED (uninstalled): entities that reference the missing mod are
78
+ [PROPOSED] quarantined, not deleted. They move into an
79
+ `orphans:<id>@<version>` store inside the save - frozen, inert,
80
+ removed from active play, but preserved. Reinstall the mod (same major
81
+ version) and they rehydrate exactly where they were. This is the
82
+ clean-uninstall guarantee: uninstalling removes a mod's active content
83
+ without ever corrupting the save.
84
+
85
+ [DECIDED 2026-07-14, decision 8] Orphan policy: option (a) - on the first
86
+ load after content is orphaned, surface a one-time per-save prompt offering
87
+ "keep frozen" (default) vs "purge N orphaned items permanently". Quarantine
88
+ stays the default and nothing is destroyed without an explicit, counted,
89
+ one-time confirmation. Rejected (b) (auto-purge trivial cosmetic orphans):
90
+ "trivial" and "cosmetic" are not reliably decidable by the engine, and silent
91
+ deletion - even of cosmetics - violates the "nothing a player earned vanishes
92
+ without a trace" guarantee. The prompt is per-save and one-time so it never
93
+ nags; declining leaves everything quarantined and reversible.
94
+
95
+ ### When a mod's content leaves the game (RATIFIED, decision 19)
96
+
97
+ Quarantine is the storage mechanism; these are the player-facing
98
+ recoveries built on top of it, so uninstalling a mod never strands or
99
+ silently destroys a character. They also fire when content is not
100
+ uninstalled but SHADOWED - a later mod in the load order `removes` or
101
+ `replaces` a record the save depends on.
102
+
103
+ - Stranded location. If the character is standing on a level, in a room,
104
+ or in a whole region that a now-missing mod generated, the load cannot
105
+ place them there. They are returned safely to the TOWN (the canonical
106
+ always-present safe location, itself core parity content), with a
107
+ message explaining why. The dungeon regenerates from the surviving
108
+ content as normal on the next descent; no half-loaded mod geometry is
109
+ ever walked.
110
+ - Stranded items. Items whose definition came from the missing mod are
111
+ not dropped and not deleted. They are moved into the player's HOME
112
+ (the game's existing persistent stash) as inert entries - visible,
113
+ labelled with their origin mod, but not equippable, usable, or
114
+ sellable while the mod is absent. Reinstalling the mod (same major
115
+ version) reactivates them in place. This uses the same
116
+ `orphans:<id>@<version>` store; the home is just where the player sees
117
+ and reclaims them.
118
+ - The stash view. A dedicated, always-reachable screen lists everything
119
+ currently quarantined - by uninstall OR by another mod's override -
120
+ grouped by the mod that owns it, showing what it is, why it is inert
121
+ ("frost uninstalled" / "shadowed by bigmonsters"), and what would
122
+ restore it ("reinstall frost >=1.0" / "move bigmonsters below frost").
123
+ Nothing a player earned ever vanishes without a trace they can find.
124
+
125
+ These recoveries are graceful degradation, not gameplay rollback: they
126
+ preserve what the player has against a tooling change, and do not let the
127
+ player undo an in-game outcome, so they sit cleanly beside the
128
+ no-save-scum rule.
129
+
130
+ ### Compatibility gating
131
+
132
+ A save refuses to load only when it genuinely cannot: an incompatible
133
+ engine version, or a missing REQUIRED dependency of an enabled mod. In
134
+ those cases the app says exactly what is missing and offers the fix
135
+ ("install core >=0.6.0" / "reinstall frost@1.x"), rather than failing
136
+ with a stack trace. Everything softer than that (a removed optional mod,
137
+ a cosmetic pack gone) degrades gracefully via quarantine.
138
+
139
+ ---
140
+
141
+ ## 2. Installing mods
142
+
143
+ ### The manifest carries everything the installer needs
144
+
145
+ `manifest.json` (see README for the base fields) gains lifecycle fields:
146
+
147
+ ```json
148
+ {
149
+ "id": "frost",
150
+ "version": "1.2.0",
151
+ "engine": ">=0.5.0 <0.7.0",
152
+ "shape": "content",
153
+ "dependencies": { "core": ">=0.5.0", "runes": "^2.0.0" },
154
+ "optionalDependencies": { "biglevels": "*" },
155
+ "loadAfter": ["runes"],
156
+ "loadBefore": [],
157
+ "saveSchema": 3,
158
+ "capabilities": ["command:add", "event:turn-start", "state:party.read"],
159
+ "repository": "https://github.com/you/frost",
160
+ "license": "CC-BY-4.0",
161
+ "screenshots": ["media/1.png"],
162
+ "changelog": "CHANGELOG.md"
163
+ }
164
+ ```
165
+
166
+ The `capabilities` list applies only to `shape: plugin` mods and is the
167
+ consent surface (section 4). Content and tile packs request none.
168
+
169
+ The vocabulary is `command:add`, `event:<name>`, `state:<domain>.read`,
170
+ `network:<host>`, `registry:<domain>`, **`display:replace`** and
171
+ **`ui:<region>.replace`**. The last two are the screen, and they are two grants
172
+ rather than one. `display:replace` is what `ModPlugin.frontend` requires -
173
+ everything the player sees of the dungeon drawn by the mod. `ui:<region>.replace`
174
+ is what `ModPlugin.hud` requires, PER REGION - `ui:messages.replace`,
175
+ `ui:sidebar.replace`, `ui:status.replace`, or `ui:*.replace` for all three - so a
176
+ mod that redraws the vitals does not have to ask for the message line as well,
177
+ and a player consenting is told which part of their screen is changing hands.
178
+
179
+ Both stand outside `registry:` deliberately, and `registry:*` covers neither: an
180
+ override wildcard grants every named game system, which is not the same thing as
181
+ owning part of the screen. They do not cover each other either. There is no
182
+ `ui:map.replace`, because the dungeon is `display:replace`'s and one region
183
+ answering to two capabilities would be two answers to "who draws this".
184
+
185
+ The vocabulary has since grown `ui:region.create`, `ui:panel.mount`,
186
+ `backup:folder`, `debug:spawn`, `debug:wizard`, `mod:install` and `mod:session`. See
187
+ `packages/mod-sdk/src/capabilities.ts`, whose own header is the reference list,
188
+ and [PLUGINS.md](PLUGINS.md) for what each one is and is not. Two things about the
189
+ family shape are worth reading here rather than there, because both are about how
190
+ a grant is PRICED rather than about what it opens. First, an action is compared as
191
+ well as a kind: `ui:*.replace` carries neither `region.create` nor `panel.mount`,
192
+ and `mod:install` does not carry `mod:session`, because in each pair neither side
193
+ is a superset. `debug:spawn` does not carry `debug:wizard` for the same reason, and
194
+ that comparison was added by the arrival of the second `debug:` action rather than
195
+ in advance of it: with only one action in the family the check read the kind alone,
196
+ which was correct by accident and would have let a mod that asked to conjure one
197
+ monster reach the depth jumps and the acquirement too. Second, a capability's
198
+ consent sentence is what makes it proportionate, so two grants whose sentences
199
+ differ cannot share a string:
200
+ `mod:install` puts a pack in the library switched OFF and the player meets it
201
+ before any of it runs, and `mod:session` switches one on for the rest of the
202
+ session, which is more rather than less.
203
+
204
+ The same pricing runs the other way, which is why `mod:install` opens two ctx
205
+ fields rather than one. It carries `ctx.reloadGame` as well as `ctx.installMod`,
206
+ because content composes at load: a grant that installed and could not apply
207
+ would leave the player holding something the running process never loads, and a
208
+ reload is nothing anybody would ask for on its own. Splitting them would have put
209
+ half an act on the consent list, which is the same defect as pricing two acts at
210
+ one line.
211
+
212
+ ### A mod that lasts one session
213
+
214
+ There is a fourth way a mod arrives, alongside the shipped installer, a folder on
215
+ disk, and a zip the player imports: it can be staged for the current browsing
216
+ session only (`packages/web/src/mod-session.ts`). The archive is held in session
217
+ storage rather than IndexedDB, the pack composes on the next reload without
218
+ waiting to be enabled, and closing the game forgets it.
219
+
220
+ Everything in this section still applies to it. The manifest is validated, the
221
+ engine range is honoured, the standards inspection runs, the origin is pinned
222
+ against an installed copy of the same id, and the pack goes through the same
223
+ composer in the same load order - a staged copy of an installed id shadows it, and
224
+ the collision is reported. What is different is the lifetime of the ARCHIVE, and
225
+ nothing else: a session pack's records are as real as any while they are loaded,
226
+ and section 4's account of what a capability does and does not fence is unchanged
227
+ by how long the mod is remembered.
228
+
229
+ Two honest limits, both recorded because the phrase "just for this session" does
230
+ not carry them. The lifetime is a convention rather than a boundary - a browser
231
+ restoring a closed or crashed window restores session storage with it - so the
232
+ mitigation is that a session mod is always listed, always marked, and always
233
+ droppable. And a save written while one was loaded stays loadable but is not
234
+ reproducible: entities in the staged namespace are quarantined on the next load,
235
+ which is correct, while a field the pack PATCHED on a core record simply returns
236
+ to its unpatched value, because a patch lives in the composition and not in the
237
+ save. `docs/PLANNED.md` carries the second as open work.
238
+
239
+ ### From a git repository (proposed design, not the shipped path)
240
+
241
+ [PROPOSED] throughout, and the heading used to read "(today)", which it was not.
242
+ The shipped installer reads `manifest.json` at a TAG and nothing else: no branch
243
+ head and no bare commit. What it actually does, including how it
244
+ picks which release to offer and what an install pins, is in
245
+ [MOD_COMPATIBILITY.md](MOD_COMPATIBILITY.md) and [../MODS.md](https://github.com/neostryder/neo-angband/blob/master/docs/MODS.md). The
246
+ design below is kept as the design of record for where the installer is going.
247
+
248
+ The user pastes a repository URL (or picks a ref). The app:
249
+
250
+ 1. Resolves a specific ref (tag preferred, else branch head, else
251
+ commit) and pins it - installs are reproducible, not "latest".
252
+ 2. Fetches the tree at that ref and reads `manifest.json`.
253
+ 3. Validates: schema, `engine` compatibility, dependency availability
254
+ and version ranges, and (for plugins) the capability list.
255
+ 4. Shows a pre-install summary: what it adds, what it patches/replaces/
256
+ removes (computed against the current load order), capabilities it
257
+ requests in plain language, size, license, author, screenshots, and
258
+ any conflicts with already-enabled mods.
259
+ 5. On confirm, materializes the mod into local storage
260
+ (content-addressed by hash), enables it, and inserts it into the load
261
+ order at the dependency-correct position.
262
+
263
+ [PROPOSED] Browser reality, stated honestly: a web page cannot speak the
264
+ git protocol or clone arbitrary hosts (CORS, no git transport). "Install
265
+ from git" in the web build means fetching the repository tarball at a
266
+ ref through the host's HTTP API (GitHub/GitLab both expose CORS-friendly
267
+ archive and raw endpoints for public repos). Private or self-hosted
268
+ repos need a user-supplied token or a small optional proxy; this is
269
+ documented, not hidden. The desktop build could clone directly, since it has a
270
+ real filesystem and a real process to run git in. Either way the installer
271
+ consumes the same pack format.
272
+
273
+ ### From a marketplace (future release)
274
+
275
+ The marketplace is a delivery layer over the same pipeline, not a
276
+ separate system. It serves pre-validated, pre-packaged `.ngpack` bundles
277
+ (the pack directory, zipped, with the manifest and a signed content
278
+ hash) from its own host, and adds browse / search / screenshots /
279
+ ratings in-app. Building it later is cheap because the bundle
280
+ format and the installer are built now, with "git" and "marketplace" as two
281
+ sources feeding one installer. [PROPOSED] The in-app browser is a view
282
+ onto that source; actually building the marketplace backend is a future
283
+ release, as noted.
284
+
285
+ ### Updating and uninstalling
286
+
287
+ - Update: the app compares the pinned ref (or marketplace version)
288
+ against upstream, shows the changelog and any migration notes,
289
+ re-checks conflicts and dependencies, then applies atomically. Before
290
+ a migration runs it takes an internal pre-migration snapshot of the
291
+ affected save (see the note under section 5 on why this is not
292
+ save-scumming) and rolls back if the migration throws.
293
+ - Uninstall: disable, then optionally delete files. The app states the
294
+ consequence up front ("3 characters use this mod; their in-world frost
295
+ content will be quarantined and restored if you reinstall") so there
296
+ is never a silent loss.
297
+
298
+ ---
299
+
300
+ ## 3. Running many mods together
301
+
302
+ ### Load order and dependency resolution
303
+
304
+ Enabled mods form an ordered list, and **later in the order wins** on genuine
305
+ conflicts (last-write-wins, the convention players know from Bethesda games).
306
+ That rule is now true of every composition layer; until 2026-08-01 graphics
307
+ modes resolved FIRST-wins, so moving a tiles mod later made it lose while the
308
+ manager's own row promised the opposite (see "One winner rule" below).
309
+
310
+ There are two order-producing functions and the split between them is the
311
+ model in one line:
312
+
313
+ - `resolveLoadOrder` **enforces**. It takes the list the player chose and
314
+ refuses an impossible one - a missing dependency, a hard cycle.
315
+ - `sortModOrder` **proposes**. It takes the same inputs plus everything anyone
316
+ merely prefers, and answers with an order the player may accept or ignore.
317
+ It cannot fail.
318
+
319
+ `sortModOrder` weighs four tiers, strongest first, and that ranking is the only
320
+ thing deciding which constraint is dropped when they contradict:
321
+
322
+ | Tier | Source | Why it ranks there |
323
+ |---|---|---|
324
+ | `hard` | `dependencies`, present `optionalDependencies` | Correctness - a pack cannot patch records that have not composed |
325
+ | `player` | what the player pinned by moving a mod | Their machine, their call |
326
+ | `author` | `loadAfter`/`loadBefore`, `prefer-mine`/`prefer-theirs` | A named guess about a named mod |
327
+ | `group` | membership in the shipped `group` order | Nobody wrote it about this pair |
328
+
329
+ **`loadAfter`/`loadBefore` used to be HARD edges**, which meant two mods each
330
+ claiming priority over the other produced `dependency cycle among packs` and the
331
+ whole set refused to launch - with neither author having done anything
332
+ unreasonable. They are `author`-tier now. On a cycle the sorter drops the
333
+ weakest edge and says which one and why; only an all-hard cycle is reported
334
+ unresolvable, and that is an impossible mod set rather than a disagreement.
335
+ This is the rule LOOT settled on: soft metadata that contradicts hard metadata
336
+ is ignored, not turned into an error neither author can fix.
337
+
338
+ **Groups** (`PACK_GROUPS`: framework, overhaul, content, gameplay, tweaks,
339
+ interface, cosmetic, late) let a mod sort correctly against mods that did not
340
+ exist when it was written. Pairwise hints require an author to have heard of the
341
+ other mod, which is why LOOT needs a hand-maintained masterlist and why groups
342
+ are the thing worth borrowing.
343
+
344
+ **Player pins survive re-sorting.** Moving a mod records the pair the player
345
+ reordered (not an absolute index, which stops meaning anything the moment
346
+ another mod is installed) and replays it as a `player`-tier edge. Without this,
347
+ an auto-sort silently undoes the placement the player just made, which teaches
348
+ them never to press it.
349
+
350
+ Determinism is a hard requirement, not a nicety: the resolved order goes into
351
+ the savefile's mod-set fingerprint, so the sort is a pure function of
352
+ (manifests, pins, current order) - no clock, no `Math.random`, and no reliance
353
+ on Set/Map iteration for anything that decides an outcome.
354
+
355
+ ### What an author may and may not decide
356
+
357
+ > **An author has total authority over their own mod's contributions, and none
358
+ > over the player's order or anyone else's mod.**
359
+
360
+ Everything in `compat` follows from that. A claim about your OWN mod (a
361
+ section's band, what it contributes) is authoritative. A claim about someone
362
+ ELSE's mod is evidence for the sorter and text for the player:
363
+
364
+ - `prefer-mine` / `prefer-theirs` - a soft ordering preference, dropped without
365
+ ceremony when it contradicts something stronger.
366
+ - `conflicts` - "these should not both run", shown as a loud warning carrying
367
+ the author's own `because`, at enable time and in the conflict pane. **Not a
368
+ refusal.** NeoForge and Factorio both block here and this engine deliberately
369
+ does not: ratified decision 18 says the engine labels rather than forbids, a
370
+ third-party author does not get a veto over the player's setup, and a
371
+ declaration goes stale when the other mod fixes the clash.
372
+ - `patches` - "when that mod is present, my section X is the compatibility patch
373
+ for it". The section is enabled only while the named mod is, and ignored
374
+ otherwise. The one claim that produces a FIX rather than a winner, which is
375
+ why it lets a compatibility patch ship inside the mod instead of as a third
376
+ download. (RimWorld's `PatchOperationFindMod`, in manifest form.)
377
+
378
+ `because` is required on every claim. A claim with no reason is one the player
379
+ cannot evaluate, and a warning that is always there and never actionable is how
380
+ a conflict list turns into wallpaper.
381
+
382
+ ### Sections: the parts of a mod
383
+
384
+ A mod used to be one atom in the load order, which made three ordinary requests
385
+ inexpressible - and they turned out to be the same request. `sections` names the
386
+ parts:
387
+
388
+ ```jsonc
389
+ "sections": [
390
+ { "id": "kobold-rebalance", "title": "Kobold rebalance",
391
+ "default": true, "priority": "late" }
392
+ ]
393
+ ```
394
+
395
+ - **Scope a claim.** `compat[].scope` names the claimant's own sections, so
396
+ "we clash, but only over the kobold changes" is sayable.
397
+ - **Place part of a mod.** `priority` is a BAND (`first`, `early`, `normal`,
398
+ `late`, `last`), not a numeric offset: an offset added to a load index means a
399
+ different neighbour every time the list changes, while every `last` section
400
+ composes after every `normal` one whatever else is installed. This is Forge's
401
+ event-priority scheme over a Bethesda-style load order, and it refuses the
402
+ arms race an integer invites, because there is nothing above `last`.
403
+ - **Switch part off.** One player toggle per section, under that mod.
404
+
405
+ A band yields to a patch target. `priority: "first"` on a section patching
406
+ `core:kobold` is a coherent wish and an impossible position, so the section
407
+ composes at the earliest legal point instead and the report says the band did
408
+ not apply. Soft loses to hard, again.
409
+
410
+ Contributions are attributed by nesting them under the section id:
411
+
412
+ ```jsonc
413
+ { "fieldPatches": { ... },
414
+ "sections": { "kobold-rebalance": { "fieldPatches": { ... } } } }
415
+ ```
416
+
417
+ **A disabled section's contributions do not exist.** They are dropped before
418
+ composition rather than composed and overridden - the same rule a disabled mod's
419
+ hooks follow.
420
+
421
+ Sections also expose a flag to the mod's own `hooks.ts`, so `rules` is exactly
422
+ "a section with a flag and no contributions". `rules` is unchanged and every
423
+ shipped manifest keeps working; the validator refuses a section whose flag a
424
+ rule already declares, so the merged flag map cannot give one name two meanings.
425
+
426
+ **What moves what:** an ordering claim moves a whole MOD; a band moves one PART
427
+ of a mod. An author who needs part of their mod placed differently from the rest
428
+ says so with a band, which needs nobody's agreement.
429
+
430
+ ### Additive vs conflicting changes
431
+
432
+ - Additive (each mod adds new records): namespaced ids keep them
433
+ distinct. Never a conflict. This is the overwhelming common case.
434
+ - Override (two mods both touch `core:kobold`): a real conflict only if
435
+ they touch the SAME FIELD.
436
+
437
+ The existing composition model (`patches`, `replaces`, `removes`) is the
438
+ lever. [PROPOSED] `patches` becomes field-granular and composable: a
439
+ patch is a set of field operations (`set`, `merge`, `addFlag`,
440
+ `removeFlag`, numeric `add`/`mul`, list `append`/`removeValue`), applied in
441
+ load order. Two mods that
442
+ patch DIFFERENT fields of the same record compose cleanly with zero
443
+ conflict. Only same-field patches conflict, and then load order decides
444
+ and the app says so. This removes the biggest source of false conflicts
445
+ in coarse whole-record systems.
446
+
447
+ ### The conflict report, over every layer
448
+
449
+ The app shows every point where more than one mod contributes, who wins, and -
450
+ crucially - **whether anyone loses at all**.
451
+
452
+ This used to cover CONTENT RECORDS and nothing else, which was one layer of
453
+ five. The other four resolved in silence, and three of the four discard
454
+ somebody's work:
455
+
456
+ | Layer | Fold | What used to happen |
457
+ |---|---|---|
458
+ | Content records | last-wins, per field | reported |
459
+ | Graphics (`grafID`) | last-wins (was first-wins) | silent, and backwards |
460
+ | Behaviour (`ModHooks`) | per hook - see below | silent |
461
+ | Rule flags | last-wins on a flat namespace | silent; two mods share one toggle |
462
+ | Autoplayer (`controller`) | single slot | silent; the second install wins |
463
+
464
+ **The fold is part of the answer**, but it is no longer part of the ANSWER TO
465
+ "WHO WINS". Every layer, and every hook, resolves in favour of the mod that
466
+ loads last; what the fold says is whether there was anything for a winner to
467
+ win. Three folds discard a contribution (`last-wins`, `last-answer`,
468
+ `single-slot`) and three combine them (`all-must-agree`, `chained`, `any-yes`),
469
+ and only the first group needs a player to do anything. Pretending the layers
470
+ resolve alike would be the RimWorld trap - XML, then xpath, then C#, each with
471
+ its own effective precedence, so "load order" quietly means three things - but
472
+ so is pretending they resolve DIFFERENTLY when they do not.
473
+
474
+ Of the eight behaviour hooks, two are `last-answer` (the earlier mod's rule
475
+ never runs), three `all-must-agree`, one `chained`, one `any-yes`, and one
476
+ `all-observe` (every handler runs and none can veto).
477
+ `MOD_HOOK_FOLDS` lives in core beside `composeModHooks`, keyed by
478
+ `keyof ModHooks` so a hook added to the interface without a fold does not
479
+ compile, and a test in
480
+ `hooks.test.ts` OBSERVES each fold from what the composition actually does
481
+ rather than restating the table - including *which* contributor ran, which is
482
+ the half that can be wrong while the table still looks right.
483
+
484
+ Every claim is **derived from what a mod actually contributes** - the refs in
485
+ its files, the keys its hooks factory returned, the grafIDs its manifest claims.
486
+ A `touches` declaration in the manifest would have been less code and would go
487
+ stale the first time an author forgot to update it, which is the failure this
488
+ report exists to catch.
489
+
490
+ The pane groups its answer three ways, because they need three different amounts
491
+ of attention: what an author DECLARED (a human wrote a reason), what is
492
+ CONTESTED (somebody's contribution is discarded - the group with a decision in
493
+ it), and what COMBINES (listed so the picture is complete, kept last so it does
494
+ not bury the group above).
495
+
496
+ A load order that fails validation (unmet dependency, engine mismatch, hard
497
+ cycle) still cannot be launched, and the reason is plain language.
498
+
499
+ ### One winner rule
500
+
501
+ **The later mod wins. Everywhere. No exceptions.** `mods.ts` ships a live menu
502
+ row reading *"Move later (loads last, wins conflicts)"*, and that row is the
503
+ specification the rest of this section is measured against.
504
+
505
+ It was false twice, and the second time was found by re-reading the claim that
506
+ the first fix had made it true:
507
+
508
+ - **2026-08-01.** `composeTileModes` and `enabledTileModes` both gave a
509
+ contested `grafID` to the FIRST claimant, so moving a tiles mod later made it
510
+ lose. Silently, and the conflict report could not see it.
511
+ - **2026-08-02.** `walkBlockedByDiggable` and `objectListTiebreak` were
512
+ `first-answer`: the composed hook walked the contributions in load order and
513
+ stopped at the first opinion, so the EARLIER mod's rule ran and the later
514
+ mod's never did. Both are now asked in reverse load order. For the comparator
515
+ that means the last mod's ordering is the primary key and earlier ones break
516
+ the ties it leaves - a lexicographic chain, still a total order, and "later
517
+ wins" for a comparator.
518
+
519
+ Two folds look like exceptions and are not. `all-must-agree` (the veto hooks)
520
+ and `any-yes` are not answering "whose answer is used?" at all: `true` from
521
+ `historyAdd` means "I have nothing to say about this entry", not "I insist it be
522
+ written", so two mods suppressing two different things do not disagree. Making
523
+ those last-wins would let a later mod's silence cancel an earlier mod's rule -
524
+ breaking both mods for a consistency nobody asked for. The invariant that
525
+ actually matters is that **no mod's opinion is ever discarded in favour of an
526
+ earlier one**, and those two discard nothing.
527
+
528
+ Two deliberate carve-outs remain, and neither is a load-order question:
529
+
530
+ - A contested Graphics row keeps the SLOT the first claimant put it in, so the
531
+ Graphics menu does not reshuffle when mods are reordered. Only which pack
532
+ draws it changes - **position, not precedence**.
533
+ - A pack in the mods FOLDER that reuses a compiled-in pack's id loses to the
534
+ compiled-in one (`mergeModSources`, `discoverMods`). That is **identity, not
535
+ order**: the two are rival candidates for the same mod rather than two mods
536
+ in a sequence, and letting a folder silently redefine what an id means would
537
+ leave the player with no way to see which one they had enabled. In a release
538
+ build the compiled-in set is EMPTY, so this rule only ever fires in dev,
539
+ against the `demo-*` framework proofs.
540
+
541
+ ### External managers (Vortex, MO2)
542
+
543
+ **RATIFIED 2026-07-27.** Integrating with Vortex and the other
544
+ popular mod managers is an explicit goal, and it sets the division of
545
+ labour between them and the game:
546
+
547
+ - **The game ships rudimentary management only.** Turning a mod on and
548
+ off, nudging one earlier/later in the order (the existing "Move earlier"
549
+ / "Move later" rows in `mods.ts`), opting out of one of its patches,
550
+ seeing what conflicts, applying a saved profile. That is the floor a
551
+ player needs to run the first-party mods and a handful of others without
552
+ extra software - not a mod-manager reimplementation.
553
+ - **Advanced management belongs to the mod manager.** Real load-order
554
+ SORTING above all (rule sets, auto-sort, bulk reordering of a large
555
+ set), plus deployment/staging, collections and bundles, per-profile
556
+ installs, update watching, and bulk install/remove. Those are solved
557
+ problems in Vortex/MO2 and they are what those tools are for; this project does not
558
+ compete with them and does not grow the in-game UI to match them.
559
+
560
+ > **AMENDED 2026-08-01: auto-sort comes back in-game.**
561
+ > The clause above putting load-order SORTING outside the game is revised; the
562
+ > rest of the division of labour stands unchanged. What moved and why:
563
+ >
564
+ > The 2026-07-27 division was drawn when sorting meant "a UI for dragging a long
565
+ > list", which is genuinely Vortex/MO2's job. It is not what sorting means once
566
+ > authors can declare compatibility: the inputs (`group`, `compat`,
567
+ > `loadAfter`/`loadBefore`, the player's pins) are all things the ENGINE reads
568
+ > and the external manager cannot see, and resolving them is one deterministic
569
+ > function, not a UI. Leaving it outside would have meant an author could state
570
+ > a preference that nothing in the game could act on.
571
+ >
572
+ > So the game gains ONE BUTTON - "Auto-sort load order..." - which proposes an
573
+ > order, shows every suggestion it could not honour, and writes nothing until
574
+ > the player accepts. It is not staging, collections, per-install profiles,
575
+ > update watching or bulk management, and none of those are coming in-game.
576
+ - **The seam is the shared on-disk format, not an API.** A pack is a plain
577
+ directory / zip with a manifest, so it is filesystem-friendly by
578
+ construction. A desktop build watches a mod directory that a Vortex or
579
+ MO2 extension deploys into and honours the explicit enabled-set and
580
+ order it finds there. One format serves both; there is no fork, and the
581
+ external tool never needs the game running to do its job.
582
+
583
+ Consequence for the engine: the ENABLED SET and the LOAD ORDER must both
584
+ be externally authorable, plain-text, and authoritative when present -
585
+ not derived state hidden in `localStorage`. The web build's
586
+ `localStorage` set (`mod-store.ts`) is the browser's stand-in for that
587
+ file, and `?mods=` is already an external override that outranks it, so
588
+ the precedence rule (external order > stored order) is settled; the file
589
+ form lands with the desktop build.
590
+
591
+ ---
592
+
593
+ ## 4. Trust, safety, and determinism
594
+
595
+ Three trust tiers, unchanged from MODS.md, made concrete at install:
596
+
597
+ - Content packs (declarative JSON): validated data, cannot execute.
598
+ Lowest bar, freely shareable.
599
+ - Tile packs: validated manifest plus images. Same posture.
600
+ - Scripted plugins: real code, run in a sandbox (a Web Worker with no
601
+ ambient DOM, network, or storage) with explicit capability grants. At
602
+ install the app lists the capabilities in plain language ("add
603
+ commands", "read party state", "network access to api.example.com")
604
+ and the mod gets nothing it did not request and the user did not
605
+ approve.
606
+
607
+ Determinism guard (RATIFIED, decisions 19 and 22 - and deliberately
608
+ modest). First, what determinism is NOT here: it is NOT the anti-save-scum
609
+ mechanism. Anti-save-scum comes from the faithful port of the original's
610
+ persisted RNG state (the full `STATE[]`/`Rand_value` in the save, so a
611
+ reload resumes the exact stream and cannot reroll) plus single-save and
612
+ terminal death - see the save-scum policy. That protection rides on saved
613
+ state, not on the run being reproducible from a seed, so it composes with
614
+ mods.
615
+
616
+ What the guard IS: a convenience and an honest label. The SDK hands every
617
+ plugin a seeded RNG and, by default, the sandbox withholds the
618
+ nondeterministic sources (wall clock, `Math.random`, ambient network) so an
619
+ author who does nothing special stays deterministic - which keeps the
620
+ unmodded-style "shareable seed" reproducibility working when their mod is
621
+ pure. That reproducibility is a nice-to-have, not a guarantee the game
622
+ depends on.
623
+
624
+ Per decision 18, cheaty and nondeterministic mods are allowed and the
625
+ engine does not forbid. A mod that wants nondeterminism (a live-multiplayer
626
+ transport, a wall-clock event, an external AI agent) declares
627
+ `nondeterministic: true` in its manifest. The engine then grants the
628
+ capabilities it asks for and marks any profile containing it as
629
+ "not reproducible / not seed-shareable" - nothing is blocked; the player is
630
+ just told what they are trading away. Note two honest consequences, both
631
+ expected: (a) any add/remove/update of mods mid-run also breaks
632
+ reproducibility-from-seed, because the mod set is part of the seed's inputs;
633
+ (b) a nondeterministic mod re-opens reload-reroll WITHIN its own mechanics
634
+ (those outcomes are not pinned to saved state) - core mechanics stay
635
+ reroll-proof because they draw from the saved seeded stream. An undeclared
636
+ plugin that trips a withheld source gets a clear author-facing error
637
+ pointing at the fix, not a silent divergence.
638
+
639
+ Save determinism mode (core-governed ratchet). The label is not just cosmetic:
640
+ every save carries a determinism mode that CORE owns and enforces regardless
641
+ of which mods are loaded. A save starts DETERMINISTIC; the first time a
642
+ determinism-affecting mod is enabled on it, core flips it to NONDETERMINISTIC
643
+ seamlessly and IRREVERSIBLY. Removing the mod later does not restore
644
+ deterministic mode - it is a one-way ratchet, so a deterministic (unmodded)
645
+ save cannot be tainted by a mod and then "cleansed" to reclaim its
646
+ reproducibility/anti-scum guarantees. Mods can trigger the flip but can never
647
+ reverse or prevent it. This is why the save block records the exact mod set
648
+ and the mode: the mode travels with the save. See PORT_PLAN.md decision 22.
649
+
650
+ Gameplay scoring mode (core-governed ratchet). A pack that changes core
651
+ gameplay declares `affectsGameplay: true` in its manifest. On the first enable
652
+ for a save, the UI warns that it will become non-scoring and asks for
653
+ confirmation. If accepted, core sets `modNoscore`. This is separate from the
654
+ determinism ratchet: a mod may be deterministic, nondeterministic,
655
+ gameplay-affecting, both, or neither. `modNoscore` never clears after a mod is
656
+ disabled or removed, and score entry rejects it independently of Angband's
657
+ reference-format-compatible `player.noscore` bitfield.
658
+
659
+ ---
660
+
661
+ ## 5. Ergonomics: designing out the usual complaints
662
+
663
+ The goal is a mod UX that avoids the pitfalls people complain about
664
+ elsewhere. Each known complaint, and the design answer:
665
+
666
+ - "A mod broke my save / I cannot uninstall safely."
667
+ -> String-id references, per-mod save namespaces, per-mod migrations,
668
+ and quarantine-on-uninstall. Uninstalling is reversible.
669
+ - "Load order is arcane (hand-sorting plugin files, external sorters)."
670
+ -> Auto-sort by declared dependencies, so a correct order needs no
671
+ human. Where a real same-field conflict leaves a genuine choice, the
672
+ conflict report names it in plain words and says who currently wins -
673
+ and the sorting itself is done in the player's mod manager (Vortex/MO2),
674
+ which is already built for it, over the shared on-disk order.
675
+ - "Silent conflicts, mystery crashes mid-game."
676
+ -> A pre-launch conflict report and a validation gate. If it launches,
677
+ it composed cleanly; if it will not, you are told why in plain words.
678
+ - "Dependency hell / missing masters."
679
+ -> Dependency resolution with version ranges and a clear "this also
680
+ needs runes >=2.0 - install it too?" step. Never launches with unmet
681
+ requirements.
682
+ - "Where do I even get mods, and is this download safe?"
683
+ -> In-app install from trusted git sources now, a browsable
684
+ marketplace later, license and author shown before install,
685
+ capabilities shown before enabling a script.
686
+ - "Updating breaks everything."
687
+ -> Pinned refs, changelog and migration preview, atomic apply with an
688
+ internal pre-migration snapshot and automatic rollback on failure.
689
+ - "I cannot tell what a mod actually changes."
690
+ -> The computed diff view: records added, patched, replaced, removed,
691
+ fields touched, and capabilities requested.
692
+
693
+ ### Profiles (a feature players will expect once they have it)
694
+
695
+ [PROPOSED] A profile is a named, ordered mod set. A character/save is
696
+ bound to the profile that created it (that is what the manifest block
697
+ records). You can keep a vanilla character and a heavily modded one side
698
+ by side with no cross-contamination, and switch a character's profile
699
+ only through a guarded flow that runs the appropriate migrations or
700
+ quarantine. Profiles are shareable (export/import a small profile file:
701
+ ids, versions, sources, order) so a friend can one-click reproduce your
702
+ setup. Note the deliberate asymmetry with saves: profiles are meant to
703
+ be shared; savefiles are not casually exportable, because the engine's
704
+ determinism plus a shared seed plus a shared profile already reproduces
705
+ a playthrough, and freely exportable saves would undercut the
706
+ no-save-scum guarantee (see save-scum policy).
707
+
708
+ Why the pre-migration snapshot in section 2 is not save-scumming: it is
709
+ an operational safety net that only ever restores when a migration
710
+ throws, and it is not exposed as a "load an earlier save" command. It
711
+ protects against tool failure, not against the player's own bad luck.
712
+ The no-save-scum rule bars player-facing rollback of gameplay outcomes;
713
+ this is neither player-facing nor a gameplay rollback.
714
+
715
+ ### Safe mode
716
+
717
+ [PROPOSED] If an enabled combination fails to boot, the app offers a
718
+ one-click "start with mods disabled" recovery so a bad mod can never
719
+ brick access to the app or to a save.
720
+
721
+ ---
722
+
723
+ ## 6. Build order (so this is real, not aspirational)
724
+
725
+ Seams and formats first (they are cheap now and expensive to retrofit),
726
+ UI next, marketplace last:
727
+
728
+ 1. Now, as the save system and loader land: string-id serialization,
729
+ the namespaced save blocks and per-mod bags, the field-level patch/
730
+ merge composer, the load-order + dependency resolver, the capability
731
+ model, and the conflict-report computation. These are engine seams.
732
+ 1b. THE JOINING STEP (added 2026-07-14, see MOD_INTEGRATION_PLAN.md): the
733
+ engine seams from item 1 must actually be wired into the running game -
734
+ a loader that resolves + composes the pack set at boot, capability
735
+ enforcement on the perceive/act facades, the agent controller installed
736
+ in the host, and the turn loop routed through the event bus. A
737
+ 2026-07-14 audit found item 1's seams are built and tested but have no
738
+ runtime caller; this is Wave 1 of the integration plan and it precedes
739
+ the UI below.
740
+ 2. Next: the in-app mod manager UI - deliberately rudimentary per the
741
+ 2026-07-27 division of labour (list, enable/disable, a one-step
742
+ earlier/later nudge, per-patch opt-out, install-from-url, conflict view,
743
+ capability consent, profiles), plus the one auto-sort button the
744
+ 2026-08-01 amendment above adds. Bulk reordering, staging, collections
745
+ and update watching stay the external manager's job.
746
+ 3. Future release: the marketplace backend and in-app browser, plus the
747
+ externally-authored enabled-set/order file and a Vortex/MO2 extension
748
+ over the shared on-disk format.
749
+
750
+ A `neo-pack` CLI (validate + bundle, a sibling of `neo-linoleum`) ships
751
+ alongside so authors can check a pack in CI before publishing, and the
752
+ repo carries sample mods that CI installs and runs.
753
+
754
+ ---
755
+
756
+ ## Decisions (ratified 2026-07-08, PORT_PLAN.md decision 19)
757
+
758
+ 1. String-id (not index) serialization as the load-bearing rule. [DECIDED]
759
+ 2. Quarantine (freeze + restore) as the default uninstall behavior, with
760
+ a one-time keep/purge prompt for orphans. [DECIDED]
761
+ 3. Last-in-load-order-wins with field-level patch composition. [DECIDED]
762
+ 4. Determinism guard on state-affecting plugins - AS A WARNING AND LABEL
763
+ WITH AN OPT-OUT, NOT A BAR (cheaty and nondeterministic mods are
764
+ allowed; see section 4 and PORT_PLAN.md decision 18). [DECIDED, changed]
765
+ 5. Profiles bound to saves, profiles shareable but saves not. [DECIDED]
766
+ 6. Pre-migration snapshot as operational safety, reconciled with the
767
+ no-save-scum rule. [DECIDED]
768
+ 7. Uninstall recovery: stranded characters return to town, mod items are
769
+ quarantined to the player's home and reactivate on reinstall, and a
770
+ stash view surfaces everything quarantined or shadowed. [DECIDED]
771
+ 8. Orphan policy: quarantine by default with a one-time per-save keep/purge
772
+ prompt (keep default); no auto-purge. [DECIDED 2026-07-14]
773
+ 9. Integrate with Vortex and the other popular mod managers, and split the
774
+ labour with them: the game keeps rudimentary management (enable/disable,
775
+ per-patch opt-out, a one-step order nudge, conflict report, profiles)
776
+ and advanced management - load-order sorting above all - is the external
777
+ manager's job over the shared on-disk pack format. The enabled set and
778
+ the load order must therefore be externally authorable and authoritative
779
+ when present. [DECIDED 2026-07-27]
780
+ 10. A mod is the unit the player switches; its patches ride with it. A
781
+ disabled mod's patches DO NOT EXIST (no flag, nothing to toggle,
782
+ faithful 4.2.6); enabling a mod turns its whole patch set on at once,
783
+ and each patch is then individually switchable so a player can take the
784
+ set minus one. `default: true` on a rule means only "on once its own mod
785
+ is on". [DECIDED 2026-07-26, wording clarified 2026-07-27]