@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.
- package/README.md +1 -1
- package/dist/standards.d.ts +1 -1
- package/dist/standards.js +1 -1
- package/docs/AUTHORING.md +695 -0
- package/docs/MOD_COMPATIBILITY.md +477 -0
- package/docs/MOD_LIFECYCLE.md +785 -0
- package/docs/MOD_REACH.md +1538 -0
- package/docs/MOD_SEAMS.md +1024 -0
- package/docs/PLUGINS.md +1567 -0
- package/docs/README.md +457 -0
- package/docs/REGION_INPUT.md +867 -0
- package/docs/REQUIREMENTS.md +102 -0
- package/docs/tutorials/01-tweak-a-value.md +134 -0
- package/docs/tutorials/02-add-an-item.md +234 -0
- package/docs/tutorials/03-add-a-monster.md +210 -0
- package/docs/tutorials/04-change-a-spell.md +124 -0
- package/docs/tutorials/05-hook-behaviour.md +138 -0
- package/docs/tutorials/06-add-an-option.md +140 -0
- package/docs/tutorials/07-add-an-artifact.md +227 -0
- package/docs/tutorials/README.md +183 -0
- package/package.json +5 -2
- package/src/standards.ts +1 -1
|
@@ -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]
|