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

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,695 @@
1
+ # Authoring shortcuts: drafting a record that actually works
2
+
3
+ Adding a record to a pack has never been the hard part. It is JSON, and
4
+ composition takes it. Adding a record that **works** is the hard part, and it is
5
+ hard in a way no error message reaches:
6
+
7
+ - an object with no `alloc` is legal, loads cleanly, and never appears in the
8
+ dungeon;
9
+ - a monster whose `base` is misspelled is legal, loads cleanly, and binds to
10
+ nothing;
11
+ - a forty-first potion is legal, loads cleanly, and consumes the last unused
12
+ flavour, so some other potion quietly stops being distinguishable.
13
+
14
+ Nothing in the pipeline can say any of that, because nothing in the pipeline
15
+ knows what a working record looks like. Core's own 3,279 records know, and the
16
+ SDK asks them.
17
+
18
+ Everything on this page is in `@rpgm-tools/neo-angband-mod-sdk` and needs no
19
+ game running.
20
+
21
+ ## Two ways in, and the `import` is only one of them
22
+
23
+ An offline tool installs the package and imports it, which is what every example
24
+ below does. That path needs core's records from somewhere, and `coreRecords` in
25
+ those examples is that: the pack's JSON, keyed by file stem.
26
+
27
+ **A plugin inside a running game takes neither step.** A plugin resolves no bare
28
+ specifier, so the import would not work; and it does not need a copy of core's
29
+ records, because the game it is running in already composed them. Both arrive on
30
+ `ctx`:
31
+
32
+ ```js
33
+ register(host, ctx) {
34
+ const records = ctx.composedRecords; // the coreRecords argument
35
+ if (!records) return;
36
+ const drafted = ctx.authoring.draftRecord( // the imported barrel
37
+ "object",
38
+ { name: "& Sludge Dagger~", type: "sword", level: 20 },
39
+ records,
40
+ );
41
+ }
42
+ ```
43
+
44
+ `ctx.composedRecords` is better than a shipped copy of the pack would be: it is
45
+ what THIS game composed, so every enabled mod's records are in it too, each
46
+ carrying its provenance. A tool drafting against a bundled snapshot could not see
47
+ them and would report a reference to another mod's sword as dangling. See
48
+ [PLUGINS.md](PLUGINS.md#authoring-ctxauthoring-and-ctxcomposedrecords) for the
49
+ guard rules and what each field is absent for.
50
+
51
+ ## Writing another mod's extension field
52
+
53
+ Your mod may write `<owner>:<field>` only after declaring `<owner>` in
54
+ `dependencies` or `optionalDependencies`. Without that declaration the write
55
+ is refused, the field is rolled back, and the fault names your mod; later edits
56
+ to that field made from the refused value are rolled back too. Declare your own
57
+ fields under your own id as usual.
58
+
59
+ ---
60
+
61
+ ## The one-call version
62
+
63
+ ```ts
64
+ import { draftRecord } from "@rpgm-tools/neo-angband-mod-sdk";
65
+
66
+ const { record, suggestions, findings, modelledOn } = draftRecord(
67
+ "object",
68
+ { name: "& Sludge Dagger~", type: "sword", level: 20 },
69
+ coreRecords, // { object: [...], object_base: [...], ... }
70
+ );
71
+ ```
72
+
73
+ `record` comes back complete:
74
+
75
+ ```json
76
+ {
77
+ "type": "sword",
78
+ "graphics": { "glyph": "|", "color": "W" },
79
+ "level": 20,
80
+ "weight": 140,
81
+ "cost": 300,
82
+ "alloc": { "common": 20, "minmax": "20 to 100" },
83
+ "attack": { "hd": "3d5", "to-h": "0", "to-d": "0" },
84
+ "name": "& Sludge Dagger~",
85
+ "power": 8
86
+ }
87
+ ```
88
+
89
+ `modelledOn` says `"& Katana~"`, and every number that was chosen carries its
90
+ evidence:
91
+
92
+ ```
93
+ cost = 300 <- the median of the 7 core object records closest to level 20 with type "sword"
94
+ weight = 140 <- the median of the 7 core object records closest to level 20 with type "sword"
95
+ ```
96
+
97
+ `findings` holds what is still wrong with it: here, one hint that it has no
98
+ `desc`.
99
+
100
+ ### Why "modelled on", not "assembled from defaults"
101
+
102
+ The first version of this built the shape from how often each field appears
103
+ across the whole file, and produced a sword carrying an `armor` block, because
104
+ 59% of core's objects have one. **Field frequency across a file is not a fact
105
+ about any record in it.** So the shape is taken from core's nearest comparable
106
+ record and only the numbers are averaged.
107
+
108
+ A model never lends the fields that would confer behaviour or identity:
109
+ `flags`, `values`, `slay`, `brand`, `curse`, `effect`, `act`, `blow`, `spells`,
110
+ `name`, `desc`, `msg`. A template that quietly grants powers hands you an item
111
+ that does things you never asked for and would not think to look for.
112
+
113
+ ---
114
+
115
+ ## The pieces, separately
116
+
117
+ Every step of `draftRecord` is callable on its own.
118
+
119
+ | Call | Answers |
120
+ |---|---|
121
+ | `describeFile(file)` | what does a record of this kind contain? |
122
+ | `requiredFields(file)` | what do **all** of core's records here carry? |
123
+ | `fieldUsage(file)` | every field, most-used first, with its share |
124
+ | `templateRecord(file, scope)` | a starting record: `"required"`, `"common"` (default) or `"all"` |
125
+ | `peersFor(file, draft, records)` | which of core's records are comparable to this one |
126
+ | `suggestFields(file, draft, records)` | what core's comparable records would put in the gaps |
127
+ | `checkRecords(subject, all)` | every way these records will silently not work |
128
+ | `RECORD_BLUEPRINTS` | the raw measurement: per file, per field, count / types / range / observed values |
129
+
130
+ ### "What should it cost?"
131
+
132
+ A price is not derivable from first principles (Angband's costs are hand-set)
133
+ but it **is** derivable from precedent, and precedent is what core's 375 objects
134
+ are. `suggestFields` narrows twice: to the same item type, then to the seven
135
+ records nearest in level. Only numeric fields are suggested; a name, a
136
+ description or a set of flags is a design decision.
137
+
138
+ With no comparable record it falls back to the file-wide median and says so in
139
+ the evidence line, so a weak suggestion is never dressed up as a strong one.
140
+
141
+ ---
142
+
143
+ ## What `checkRecords` finds
144
+
145
+ Two arguments, and the split is the whole design:
146
+
147
+ ```ts
148
+ checkRecords(subject, all)
149
+ ```
150
+
151
+ `subject` is what is **reported on**: your records. `all` is what they may
152
+ **resolve against**: core plus every loaded pack. Checking a mod against itself
153
+ would report every reference to core as broken.
154
+
155
+ Findings are graded, and **nothing here refuses anything**. The refusals live in
156
+ the manifest validator and the declared-field rule, where the rules are the
157
+ engine's own.
158
+
159
+ ### It also runs when the GAME loads your mod
160
+
161
+ Since 2026-08-09 this is not only a build-time tool. `composeContentPacks`, the
162
+ function every host composes through, runs the same check over every pack it
163
+ loads and puts what it finds on that mod's own row in the mod manager, so a
164
+ player who installs your mod from a zip sees the same sentences you do. Three
165
+ differences from `build()`, all deliberate:
166
+
167
+ - **`warn` and above only.** A `hint` is drafting advice and belongs where you
168
+ are looking at the draft. On a player's screen dozens of them would bury the
169
+ one line that matters.
170
+ - **The base game is not reported on.** Core's own data raises warnings against
171
+ core's own blueprint; those are upstream warts the port keeps on purpose.
172
+ - **A patch is checked as the record it produced**, not as you wrote it, so
173
+ `{"speed": 120}` is not a record missing twenty fields.
174
+
175
+ The practical consequence: **your `build()` output is what your users will see.**
176
+ If it is clean at `warn`, their mod manager is quiet. There is nothing extra to
177
+ run and nothing to opt into.
178
+
179
+ | Level | Meaning | Examples |
180
+ |---|---|---|
181
+ | `error` | the record cannot work | a required field is absent; an artifact with no `base-object` |
182
+ | `warn` | it loads and will not do what it looks like it does | a dangling reference; no `alloc`; a field written as the wrong type |
183
+ | `hint` | worth a look | an unfamiliar field name (with a "did you mean"); no `desc`; nothing to attack with |
184
+
185
+ ### Dangling references
186
+
187
+ `REFERENCE_EDGES` declares 37 fields that name another record: `object.type`
188
+ into `object_base`, `monster.base` into `monster_base`, `ego_item.slay` into
189
+ `slay`, `artifact.act` into `activation`, and so on. Every edge is measured
190
+ against core's own data by `references.test.ts`, so an edge that is wrong is a
191
+ test failure rather than a false alarm in your mod.
192
+
193
+ References resolve against **core plus your own new records**, so a mod that
194
+ adds an `object_base` and then an object of that new tval is not told its own
195
+ tval is missing.
196
+
197
+ An unresolved reference is a **warning, never a refusal**, and the reason is
198
+ recorded: core's own data contains references that do not resolve.
199
+ `artifact.txt` says `base-object:soft armour:...` while `object_base.txt` and
200
+ `list-tvals.h` both spell it `soft armor`; fourteen artifact base objects
201
+ (Phial, Arkenstone, several rings) name svals `object.txt` never defines. Those
202
+ are Angband 4.2.6's, reproduced exactly under the parity mandate. A rule strict
203
+ enough to reject them would reject Angband.
204
+
205
+ ### Companion steps
206
+
207
+ `COMPANION_RULES` is the list of things the record is fine without and **you**
208
+ are not. They are all warnings or hints, because every one of them is legal:
209
+ an object with no `alloc` is exactly how core defines an item that only comes
210
+ from a store.
211
+
212
+ The one that is not a per-record rule: **flavour pressure.** Angband hands each
213
+ object of a flavoured type its own flavour (`potion`, `scroll`, `ring`,
214
+ `amulet`, `staff`, `wand`, `rod`, `mushroom`); past that point unidentified
215
+ items start sharing. Core ships 59 potion flavours for 41 potions, so there is
216
+ room for eighteen more before it bites. Counted from the composed data, so a mod
217
+ that adds flavours as well as objects gets the credit for them.
218
+
219
+ ---
220
+
221
+ ## Assembling a whole mod
222
+
223
+ `ModProject` is the same shortcuts wrapped around a manifest, and it composes
224
+ through the real pipeline before it says anything.
225
+
226
+ ```ts
227
+ import { modProject, draftRecord } from "@rpgm-tools/neo-angband-mod-sdk";
228
+
229
+ const build = modProject({
230
+ id: "sludge",
231
+ name: "Sludge",
232
+ version: "1.0.0",
233
+ shape: "content",
234
+ author: "you",
235
+ repository: "https://github.com/you/sludge",
236
+ engine: ">=0.19.0",
237
+ dependencies: { core: "*" },
238
+ })
239
+ .declareField({ name: "sludge", files: ["object"], type: "object" })
240
+ .add("monster", draftRecord("monster", { name: "sludge fiend", base: "icky thing", depth: 25 }, core).record)
241
+ .patchFields("object", "core:sword--dagger", [
242
+ { op: "set", path: "sludge:sludge", value: { turns: 5 } },
243
+ ])
244
+ .build(corePack);
245
+
246
+ build.files; // [{ path: "manifest.json", contents }, { path: "monster.json", contents }, ...]
247
+ build.findings; // worst first
248
+ build.problems; // composition's own refusals
249
+ build.ok; // false if anything is at `error`
250
+ ```
251
+
252
+ Three things it deliberately does:
253
+
254
+ - **No filesystem.** `emit()` hands back paths and bytes; writing them is yours.
255
+ The same builder works from a CLI, from a test, and from an in-game editor.
256
+ - **Checks the composed result**, not the draft. A patch that breaks a reference
257
+ is invisible in your own files, because your files do not contain the record
258
+ it broke.
259
+ - **Reports instead of throwing.** A missing dependency is an `error` finding,
260
+ not a stack trace.
261
+
262
+ `build.ok` ignores warnings on purpose: every warning it can produce is
263
+ something core's own data does somewhere, so a builder that refused on them
264
+ would refuse to build Angband.
265
+
266
+ ---
267
+
268
+ ## What a mod can add a record to: 41 of 44 files
269
+
270
+ **Measured over the shipped pack**, and it used to be 24. Composition merges a
271
+ file per record when every record has a ref no sibling claims, and it asks
272
+ `packages/mod-sdk/src/record-key.ts` what a ref is, which is `name` for most
273
+ files and something else where upstream's identity is something else.
274
+
275
+ Until 2026-08-08 the test was "a unique `name`", and three files failed it on
276
+ core's own data, because Angband's convention for a greater form is to reuse the
277
+ name with marks: `Acquirement` and `*Acquirement*`, `Little eruption` and
278
+ `Little eruption+`, and `ego_item` ships 23 names twice over. So a mod adding
279
+ one object replaced all 375 of core's, one ego replaced all 107, one vault all
280
+ 162. Those were the three files most worth adding to. They now merge per record:
281
+
282
+ | File | Records | A mod adding one record... |
283
+ |---|---|---|
284
+ | `object` | 375 | adds one, 376 |
285
+ | `ego_item` | 107 | adds one, 108 |
286
+ | `vault` | 162 | adds one, 163 |
287
+ | `store`, `flavor`, `brand`, `slay`, `object_base`, `trap`, `names`, ... | - | adds one, keyed by whatever upstream keys it by |
288
+
289
+ **The three that still take a whole file, and why.** `constants` and `visuals`
290
+ are config singletons: their identity *is* the file, the host binds exactly one,
291
+ and "I shipped `constants.json`" means "use mine". `history` has no per-record
292
+ identity at all: a history record is `{chart:{chart,next,roll}, phrase}` and
293
+ every part of that is a value a mod would legitimately change. For those three,
294
+ `ModProject.build` still raises `file/whole-file-replacement` as an `error`,
295
+ because replacing the base game's copy of a file is not something to discover
296
+ from a line in a list.
297
+
298
+ ### What a record is called
299
+
300
+ Refs did not move. The per-record identity was already what
301
+ `patchFields` / `replace` / `remove` used, so every ref that resolved before
302
+ still resolves:
303
+
304
+ - `object` is `type + name`, so the Dagger is `core:sword--dagger`;
305
+ - `ego_item` is `name`, plus a `#` discriminator where core ships a name twice,
306
+ as in `core:of-acid#shot-arrow`;
307
+ - `store` is its `STORE_*` code, `brand` and `slay` their `code`, `flavor` its
308
+ base tval, and so on.
309
+
310
+ A record answers to **several** refs: its base key, its discriminated form, and
311
+ the pre-2026-08-08 lossy slug as an alias, so nothing an author wrote against an
312
+ older engine stops working. An alias is dropped where it would shadow a
313
+ *different* record's real name: `*Healing*`'s old ref is plain `Healing`'s
314
+ current one, and a record's own history must not cost another record its name.
315
+
316
+ That is **8 of the pack's 19 legacy aliases**, and it depends on core's data
317
+ rather than on the mark. `*Acquirement*` loses its alias, because core ships a
318
+ plain `Acquirement` scroll. `*Destruction*` keeps both of its, as a scroll and
319
+ as a staff, because core ships no plain `Destruction` at all, so there is
320
+ nothing for it to shadow. `of *Slay Orc*` loses its and `of *Slay Animal*` keeps
321
+ its, for the same reason. The full census is asserted row by row in
322
+ `record-key.test.ts`, so the count cannot drift back into prose.
323
+
324
+ None of the 8 cost anybody a working ref: every file carrying a legacy alias is
325
+ one that had *no* per-record addressing before the key table existed.
326
+
327
+ ### Where a new record lands, and why it matters
328
+
329
+ At the **end**, after core's. That is not cosmetic. Upstream's `sval` is not a
330
+ field in the data: it is a counter, bumped per object base in file order
331
+ (`parse_object_type`, `reference/src/obj-init.c`), and `kidx` is the position in
332
+ the file. Appended, every one of core's 375 objects keeps its index, name, tval
333
+ and sval, and the new one takes the next free sval of its own base. Prepended,
334
+ every sword in the game would shift by one.
335
+
336
+ Composition appends because core is pack zero and a mod that declares `core` as
337
+ a dependency loads after it. `packages/web/src/mod-added-record.test.ts` binds
338
+ core's pack with and without one added object and asserts the whole table, not a
339
+ sample. The one thing that does move is the tail of dummy kinds `bindCore`
340
+ creates for special artifacts whose base sval `object.txt` never defines (the
341
+ Phial, the Star, the rings of power); their array index shifts by one and
342
+ nothing depends on it, because a savefile stores a namespaced string `kindId`
343
+ rather than a `kidx`.
344
+
345
+ ### Your artifact and the `birth_randarts` option
346
+
347
+ An artifact your mod adds **survives** a character born with random artifacts
348
+ turned on. Every other artifact in the game is redesigned into a different item;
349
+ yours keeps the name, the base object and the numbers you wrote.
350
+
351
+ That is measured, in `packages/core/src/obj/randart-mod-artifact.test.ts`, and it
352
+ is worth knowing WHY, because the mechanism is position rather than a rule.
353
+ Upstream's `design_artifact` looks up an artifact's base kind once and its
354
+ skip-the-fixed-artifacts loop never refreshes that lookup, so the moment the loop
355
+ starts on a quest artifact it keeps skipping to the end of the array. Angband's
356
+ two quest artifacts are the last two records in the file, and your records are
357
+ appended after core's, which puts them behind the point where the skipping
358
+ starts. The port reproduces the quirk exactly, because a behavioural wart a
359
+ player can observe is core's to keep.
360
+
361
+ Two consequences for an author. Your artifact is not a random artifact even in a
362
+ random-artifact game, so a player who chose that option to be surprised will
363
+ still meet yours as you designed it. And nothing about that is a guarantee the
364
+ port makes on purpose, so the test above also measures the converse case: if the
365
+ order records are bound in ever changes, it fails and names the reason.
366
+
367
+ ---
368
+
369
+ ## Shipping resources: sounds, a font, pref files, help pages, art
370
+
371
+ Records are not the only thing a mod folder can hold. Six other categories are
372
+ declared in one `resources` array in your manifest, each naming a `kind` and a
373
+ `path` **inside your mod folder**. The kinds are `sound`, `font`, `prefs`,
374
+ `help`, `art` and `locale` (`ResourceKind`,
375
+ `packages/mod-sdk/src/resources.ts`):
376
+
377
+ ```json
378
+ "resources": [
379
+ { "kind": "sound", "path": "sounds" },
380
+ { "kind": "font", "path": "fonts/terminal.json" },
381
+ { "kind": "prefs", "path": "prefs/colours.prf" },
382
+ { "kind": "help", "path": "help/lore.txt", "slot": "lore", "name": "The lore" },
383
+ { "kind": "art", "path": "art/splash.txt", "slot": "splash" }
384
+ ]
385
+ ```
386
+
387
+ `path` is never a URL. You cannot know where the game is serving your mod from,
388
+ and two of the three places a mod can live have no path at all: a folder the
389
+ player picked, and a mod installed from a repository, which lives in the
390
+ browser's database. The host composes your path with your mod's own resolver.
391
+
392
+ | kind | what it is | several mods? |
393
+ | --- | --- | --- |
394
+ | `sound` | a **directory** of samples named as `sound.prf` names them, `.mp3` or `.ogg` | the last enabled one wins |
395
+ | `font` | a bitmap font, `{ "w", "h", "glyphs" }`, one scanline number per row | the last enabled one wins |
396
+ | `prefs` | a `.prf` in ui-prefs.c's own grammar; ASCII glyphs, colours and sound prefs apply at install, and TILE assignments layer over a graphics pack's own prefs on every map build | **all of them apply**, in load order |
397
+ | `help` | one page of plain text | per `slot` |
398
+ | `art` | one screen of `{colour}...{/}` markup | per `slot` |
399
+ | `locale` | one language, `slot` being its BCP 47 tag | per `slot` |
400
+
401
+ Four things that will otherwise cost you an afternoon:
402
+
403
+ - **A `.prf`'s `%:` includes resolve beside the file you declared.** They are
404
+ followed (they were silently skipped before #278), to the same depth the
405
+ parser allows, and every one of them, including an include's own includes,
406
+ is looked up in the directory of the `path` in your manifest. So
407
+ `prefs/colours.prf` saying `%:shared.prf` reads `prefs/shared.prf`. A name
408
+ that does not resolve is skipped without a message, which is what upstream
409
+ does; if a rule of yours is not taking effect, check the spelling of the
410
+ include before anything else.
411
+ - **A `.json` resource must sit in a subdirectory.** A top-level `.json` is read
412
+ as a record contribution, so `font.json` would be handed to the record
413
+ composer, which has no content file by that name, and your mod would load with
414
+ no font and no complaint anywhere. `fonts/font.json` is fine.
415
+ - **`art` is text, not an image.** The terminal is a glyph grid; nothing paints a
416
+ bitmap into it. Upstream's own splash is text (`lib/screens/news.txt`), and
417
+ `$VERSION` is substituted in yours exactly as it is in that one. Your art is
418
+ clamped to 21 rows and the two credit lines are appended after it.
419
+ - **A `help` slot that matches one of the game's REPLACES that page**; any other
420
+ slot adds one. The ids are `commands`, `symbols`, `guide`, `community`. Use one
421
+ of those if your conversion's keys are not Angband's; use your own otherwise.
422
+
423
+ ### What happens when a resource is wrong
424
+
425
+ Nothing is taken away except that resource. A pref file that will not parse costs
426
+ you the pref file, not your records, not your sound pack, not the mod. But it is
427
+ never silent: whatever could not be used is written on your mod's row in the mod
428
+ manager, in a sentence saying what was wrong with it.
429
+
430
+ Three checks run, and the last one can only run on the player's machine:
431
+
432
+ 1. **Your declaration**, at build time and again at load: an unknown kind, a path
433
+ leaving your folder, an extension the kind cannot be, a slot no screen paints.
434
+ A `slot` on a kind that has no slots is refused rather than ignored, because a
435
+ silently dropped key is a belief of yours that would survive to ship.
436
+ 2. **Your file list.** A mod read from a folder or installed from a repository
437
+ arrives with every filename it holds, so a typo is caught without a single
438
+ request. (Not available for a mod compiled into the app; check 3 catches
439
+ those.)
440
+ 3. **The machine.** Whether this build can play `.mp3` or `.ogg` at all, and
441
+ whether your font JSON is structurally a font. Only opening the file can say.
442
+
443
+ `demo-resources` is a working example of four of the six, and
444
+ `packages/web/src/mod-resources.node.test.ts` reads it from disk in CI. It is not
445
+ a mod you can install: the `demo-*` mods under `packages/web/mods/` are framework
446
+ proofs compiled into DEV builds only, and discovery strips them from a release
447
+ build (`isShippedMod`, `packages/web/src/mod-store.ts`). Read it in this
448
+ repository rather than looking for it in the game.
449
+
450
+ ---
451
+
452
+ ## Translating the game
453
+
454
+ English ships in the game and is what a player sees with no mod installed. A
455
+ translation is a `locale` resource, a JSON file whose `slot` is its language
456
+ tag:
457
+
458
+ ```json
459
+ {
460
+ "tag": "de",
461
+ "name": "Deutsch",
462
+ "messages": {
463
+ "help.commands.label": "Verfügbare Befehle",
464
+ "shop.stock": "{n, plural, one {# Gegenstand} other {# Gegenstände}}"
465
+ }
466
+ }
467
+ ```
468
+
469
+ `tag` must match the `slot` that declared the file. They are two statements of
470
+ the same fact and the check refuses them when they disagree: the slot decides
471
+ which language your file *is offered as*, and the tag decides what it *is*.
472
+
473
+ **You do not have to translate everything.** A missing id falls back through the
474
+ region (`pt-BR` -> `pt`) to English, so a partial catalogue reads as part English
475
+ rather than as a screen of blanks.
476
+
477
+ ### Patterns, not sentences you glue together
478
+
479
+ Messages are [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/),
480
+ a subset, but the ordinary one, so ordinary translation tools can edit your
481
+ file:
482
+
483
+ | you write | you get |
484
+ | --- | --- |
485
+ | `{name}` | the value |
486
+ | `{n, number}` | grouped for your locale (`1.234.567` in German) |
487
+ | `{n, plural, one {# ring} other {# rings}}` | the right arm, `#` being the number |
488
+ | `{n, plural, =0 {nothing} other {#}}` | an exact value short-circuits the rules |
489
+ | `{g, select, male {Er} female {Sie} other {Es}}` | an exact match |
490
+ | `{n, selectordinal, one {#.} other {#.}}` | ordinals |
491
+ | `'{` | a literal brace |
492
+
493
+ **Use the plural arms your language actually has.** They come from the platform's
494
+ own rules, so Polish gets `one`/`few`/`many`/`other` and Arabic gets six, and the
495
+ game never has to know which. Writing a bare `{n} Ringe` and letting the number
496
+ do the work is the single most common way a translation ends up wrong.
497
+
498
+ ### When words are not enough
499
+
500
+ Some text is *assembled*, not written. An object's name is built from a pattern
501
+ like `& Scroll~ titled #`. The `~` is an English pluralizer, the `&` becomes
502
+ `a`/`an` by the vowel after it, and the count goes in front. If your language
503
+ counts with a classifier, inflects for case, or has no plural `s`, no amount of
504
+ word replacement will get you there.
505
+
506
+ For that, a locale replaces the **function**. Those live in code, so a
507
+ translation that needs them ships a `plugin.js` alongside its JSON and calls
508
+ core's `registerLocale` with its own `forms`:
509
+
510
+ ```js
511
+ export function register(host, ctx) {
512
+ const core = ctx.core.coreForms();
513
+ ctx.core.registerLocale({
514
+ tag: "de",
515
+ forms: {
516
+ // English's machinery for everything except the nouns you care about
517
+ objectNameFormat: (fmt, modstr, plural) =>
518
+ fmt.includes("Scroll")
519
+ ? (plural ? "Rollen" : "Rolle")
520
+ : core.objectNameFormat(fmt, modstr, plural),
521
+ },
522
+ });
523
+ }
524
+ ```
525
+
526
+ `coreForms()` is what makes this a small job rather than a rewrite: take
527
+ English's implementation, special-case what your language does differently, and
528
+ delegate the rest.
529
+
530
+ ### Finding what is not translated yet
531
+
532
+ Not every string in the game has been routed through the translator yet. A
533
+ **pseudo-locale** is how you find the ones that have not: the bundled
534
+ `demo-resources` mod ships `en-XA`, readable English with every letter accented
535
+ and every string bracketed. Enable it, switch to it with `?lang=en-XA`, and
536
+ anything still in plain ASCII on the screen is a string that cannot yet be
537
+ translated. Those are worth reporting.
538
+
539
+ ---
540
+
541
+ ## Renaming a player-toggleable rule
542
+
543
+ A rule `flag` is durable PLAYER STATE, not an internal name. The player's answer
544
+ is stored against that exact string in the host's own store, so replacing a flag
545
+ outright orphans their answer: the lookup misses, the rule falls back to its
546
+ declared `default`, and someone who deliberately turned your fix OFF gets it
547
+ back ON without being told. For a bug-fixes mod, whose defaults are all on, that
548
+ is the game quietly re-applying a change they had rejected.
549
+
550
+ So do not simply replace one. Map each retired flag to its current rule under
551
+ `renamedRuleFlags`:
552
+
553
+ ```json
554
+ "renamedRuleFlags": {
555
+ "bug-fixes.atomic-save": "bug-fixes.save-safety",
556
+ "bug-fixes.atomic-crash": "bug-fixes.save-safety"
557
+ }
558
+ ```
559
+
560
+ Every destination must be one of this manifest's current `rules`. The source
561
+ must NOT be: a flag you still declare is live, and consuming its stored choice
562
+ as retired would destroy a setting you are still exposing. Renaming a flag to
563
+ itself is refused for the same reason.
564
+
565
+ The host migrates its saved choices when it loads your enabled mod, before it
566
+ resolves defaults. Where several retired flags become one rule, the result is on
567
+ if ANY of them was on: turning off a fix the player had on would reintroduce a
568
+ bug they had chosen to be rid of, and re-enabling a sibling is the smaller
569
+ surprise: they can still turn the whole rule off. A choice already recorded for
570
+ the current flag wins outright, since it was made against the new release. The
571
+ old entries are then consumed, so loading again changes nothing.
572
+
573
+ ---
574
+
575
+ ## Renaming a section or turning a rule into a section
576
+
577
+ Sections persist their choices separately from rules, under the owning mod and
578
+ the section's `id`. If you rename a section, or promote a `rules[]` entry into a
579
+ section so it can gate content, put its old names in the new section's
580
+ `renamedSectionFlags` list:
581
+
582
+ ```json
583
+ "sections": [
584
+ {
585
+ "id": "text-corrections",
586
+ "title": "Text corrections",
587
+ "flag": "bugfix.textAndHistory",
588
+ "renamedSectionFlags": ["bugfix.textAndHistory", "old-text-corrections"]
589
+ }
590
+ ]
591
+ ```
592
+
593
+ Each name may find either a retired rule choice or a retired section choice. For
594
+ a previous section using its default flag, that name is its old `id`, which is
595
+ also the key the host stored; a previous section with a custom `flag` still uses
596
+ its old `id` for this purpose. Listing the current `flag` is valid and is how a
597
+ rule that became a section under the same name preserves its existing choice.
598
+
599
+ An explicit choice already stored for the current section wins. Otherwise the
600
+ host checks `renamedSectionFlags` in list order and copies the first matching
601
+ choice into the current section; if both stores happen to carry the same old
602
+ name, the old section choice wins. It consumes retired entries afterwards, so a
603
+ later load is unchanged. With no current or retired choice, the section uses its
604
+ declared `default` as usual.
605
+
606
+ ---
607
+
608
+ ## Front-end groundwork
609
+
610
+ The host draws through a renderer-neutral `GridSurface`, and its existing canvas
611
+ terminal is merely one implementation. Menus are now declarative front-end data:
612
+ request `registry:menu` and use `host.menus.register("core:game-menu", fn)` to
613
+ rewrite one named menu's rows. The id is stable and never a localized title;
614
+ each row carries a stable id plus `semantic.kind`, optional `semantic.ref`, and
615
+ small scalar `semantic.data`, so an alternative layout works from meaning rather
616
+ than parsing its label. Call `host.menus.handlerFor(id)` before registering when
617
+ you need to wrap a transformer installed by an earlier mod. A failed transform
618
+ is reported and the unmodified menu stays openable.
619
+
620
+ `ModPlugin.frontend?(ctx)` is now the one map-display slot. The later enabled
621
+ frontend wins, and only that factory is invoked; return a `WorldFrameSink` or
622
+ `undefined` to preserve the glyph terminal. The host invokes the extracted
623
+ world-render-data producer from its actual map repaint and passes the winner a
624
+ frozen, renderer-neutral `WorldFrame` snapshot: grids retain semantic
625
+ terrain, trap, object, monster, and path ids plus seen/remembered/unknown state,
626
+ while the glyph projection is only the current terminal fallback (including its
627
+ terrain-under-foreground tile inputs, even for a path over otherwise bare seen
628
+ terrain). That makes the
629
+ world data ready for an isometric or 3D consumer. TypeScript mods can write
630
+ `import type { WorldFrame, WorldFrameSink } from
631
+ "@rpgm-tools/neo-angband-mod-sdk"`; it is type-only, so it does not violate the
632
+ folder-plugin no-bare-runtime-import rule. Its Phase-4 control
633
+ executes the same producer `main.ts` calls, checks the unmodded glyph sink's
634
+ pre-frame `term.put` tuples, and proves an independently owned host sink
635
+ receives that exact frame in the same call. The Phase-5 disk fixture proves the
636
+ later plugin receives it and an unmodded control preserves glyph painting. The
637
+ snapshot has no mutable player-grid alias, so a frontend can retain a frame
638
+ without retaining live game state.
639
+
640
+ Input follows the same staged rule. `UiInput` is available to host code through
641
+ the one input door and can represent a continuous direction (vector, magnitude,
642
+ angle) without translating it to a keyboard arrow. A front-end member DOES
643
+ exist - `ModPlugin.frontend?(ctx)`, gated by `display:replace`, and pointer input
644
+ arrives per region through `RegionDeclaration.input` - so what is still absent is
645
+ narrower than "no seam": there is no plugin member for rebinding KEYS, and
646
+ `input-door.ts` is host infrastructure rather than a capability. Do not build on
647
+ key rebinding until it has one. Player keymaps keep precedence over any later
648
+ input consumer while the root owns input; an active modal, score screen, or run
649
+ interruption continues to receive the player's literal key first.
650
+
651
+ ## Knowing which mod a record came from
652
+
653
+ Every record the game binds carries `from` when a mod was involved. Reach a bound
654
+ record the way the binding exposes it - a monster race through the binding's
655
+ `races` array, an object kind through `registries.objects.kinds`, and so on;
656
+ there is no single `lookup` helper for every record type:
657
+
658
+ ```js
659
+ const race = someBoundRace; // e.g. from ctx.registries
660
+ race.from; // { owner: "demo-modtest" } - a mod ADDED it
661
+ someCoreRace.from; // { owner: "core", modifiedBy: ["qol"] } - a mod CHANGED it
662
+ anotherCoreRace.from; // undefined - core's, untouched
663
+ ```
664
+
665
+ `undefined` is the common case and it means "core's own, and nothing touched
666
+ it", exactly as `ext` does. So a plugin never has to tell "no mod" from "a mod
667
+ that left no mark", and a check like `if (race.from) ...` reads correctly.
668
+
669
+ `owner` is the pack that ADDED the record. A patch does not transfer ownership:
670
+ if your mod renames one of core's monsters, that monster is still core's - turn
671
+ your mod off and it is still there - so `owner` stays `core` and your id joins
672
+ `modifiedBy`. This matters beyond bookkeeping, because **`owner` is the
673
+ namespace a savefile stores the record under**. A monster your mod adds is saved
674
+ as `yourmod:its-name`; if it were saved as `core:its-name`, a player who removed
675
+ your mod would have a save asking the base game for content it has never heard
676
+ of, with nothing in the id to say who should have supplied it.
677
+
678
+ You do not write `from` and you cannot: it is stamped by the composer under a
679
+ reserved key that no mod can mint, because a mod's own fields must be namespaced
680
+ and the reserved key is not. Writing `"$from"` into your own JSON by hand is
681
+ ignored.
682
+
683
+ ## Regenerating the blueprint table
684
+
685
+ `packages/mod-sdk/src/blueprints.ts` is generated from the shipped pack:
686
+
687
+ ```bash
688
+ node packages/mod-sdk/scripts/gen-blueprints.mjs
689
+ ```
690
+
691
+ Do not edit it by hand. `blueprints.test.ts` re-derives the whole table from
692
+ `packages/content/pack` and fails in both directions, and separately asserts
693
+ that it agrees, file for file, with core's own generated `CORE_RECORD_KEYS`:
694
+ the day those two disagree is the day a field is an extension at one end and a
695
+ core field at the other.