@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.
- 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 +988 -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 +2 -1
|
@@ -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.
|