@rpgm-tools/neo-angband-core 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ import { Rng, ENGINE_VERSION, PARITY_BASELINE } from "@rpgm-tools/neo-angband-co
32
32
  const rng = new Rng(1234);
33
33
  console.log(rng.damroll(3, 6), rng.damroll(3, 6), rng.damroll(3, 6)); // 9 13 8
34
34
 
35
- console.log(ENGINE_VERSION, PARITY_BASELINE); // 0.16.0 4.2.6
35
+ console.log(ENGINE_VERSION, PARITY_BASELINE); // 0.18.0 4.2.6
36
36
  ```
37
37
 
38
38
  Two entry points:
package/dist/log.d.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * A log a player can hand you, and the rule that decides how much is in it.
3
+ *
4
+ * WHY THIS IS IN `core` WHEN THE ENGINE NEVER CALLS IT. It is not a parity
5
+ * module and nothing in the C corresponds to it - the honest reason is that
6
+ * `core` is the only package both the renderer and the Electron main process
7
+ * already depend on, and the alternative was two implementations of the same
8
+ * format. This project has been bitten by that shape before: two copies of a
9
+ * check, and only one of them learns. So it lives here, behind the `./log`
10
+ * subpath rather than in the barrel, next to `./host` - which is the same kind
11
+ * of thing, infrastructure the port needs and the original did not have.
12
+ *
13
+ * It does NO I/O and holds NO sinks of its own. Writing a line to a file is the
14
+ * main process's job and drawing one on a terminal is the renderer's; this
15
+ * module owns the level rule, the record, the ring, and the text - the four
16
+ * things that have to be identical on both sides of the IPC boundary or a log
17
+ * file becomes two interleaved formats.
18
+ *
19
+ * THE RING IS THE POINT, not the file. A player who hits a bug is asked for a
20
+ * report, and the report is "the last N things that happened" - which has to
21
+ * exist in memory already, because the interesting part is over by the time
22
+ * anybody presses a key.
23
+ */
24
+ /**
25
+ * Four levels, and no `trace`.
26
+ *
27
+ * `debug` is already the level nobody ships; a fifth below it would only ever
28
+ * be reached by editing a constant, which is what a comment is for.
29
+ */
30
+ export type LogLevel = "error" | "warn" | "info" | "debug";
31
+ /** Most severe first. Index is the rank, so the order here IS the ordering. */
32
+ export declare const LOG_LEVELS: readonly LogLevel[];
33
+ /** 0 for `error`, 3 for `debug`. -1 for anything that is not a level. */
34
+ export declare function logLevelRank(level: string): number;
35
+ export declare function isLogLevel(v: unknown): v is LogLevel;
36
+ /** Would a log running at `active` record something logged at `of`? */
37
+ export declare function logLevelAllows(active: LogLevel, of: LogLevel): boolean;
38
+ /**
39
+ * How much this BUILD logs, decided by its own version rather than by a setting.
40
+ *
41
+ * A released build logs warnings and errors; a build somebody is testing logs
42
+ * what it is doing as well. The two states are already written down in the
43
+ * version string and nowhere else needs to agree:
44
+ *
45
+ * `0.16.1-edge.2` a per-commit build -> info
46
+ * `0.16.0` a 0.x pre-release -> info
47
+ * `1.2.3` a finished release -> warn
48
+ *
49
+ * DELIBERATELY NOT THE UPDATE CHANNEL. The channel is a preference about which
50
+ * builds to accept next, stored in localStorage and changeable at any time; a
51
+ * player who installed a beta and then picked `stable` is still running the
52
+ * beta, and the log should still say so. Asking the version means the answer
53
+ * cannot drift from the thing it describes.
54
+ *
55
+ * While the project is 0.x this returns `info` for everything, which is correct
56
+ * and temporary: `stable` selects nothing before 1.0.0 either. Both facts stop
57
+ * being true on the same day, by themselves - see `defaultChannel`, which this
58
+ * mirrors and which `update.test.ts` ties it to.
59
+ */
60
+ export declare function defaultLogLevel(version: string): LogLevel;
61
+ /**
62
+ * One line of log, with its detail ALREADY rendered to text.
63
+ *
64
+ * Serialising at record time rather than at write time is what bounds the ring:
65
+ * a record holds a string, not a reference to a live game object that will have
66
+ * changed - or been mutated into a cycle - by the time anybody reads it. It also
67
+ * means a report cannot be the thing that throws while somebody is filing a bug.
68
+ */
69
+ export interface LogRecord {
70
+ /** Epoch milliseconds. */
71
+ readonly at: number;
72
+ readonly level: LogLevel;
73
+ /** Where it came from: `update`, `mods`, `save`. Short and stable. */
74
+ readonly area: string;
75
+ readonly msg: string;
76
+ /** The extra detail, JSON-ish, or undefined when there was none. */
77
+ readonly data?: string;
78
+ }
79
+ /** Somewhere a line goes. Given the record and the text, so it need not reformat. */
80
+ export type LogSink = (rec: LogRecord, line: string) => void;
81
+ /** How many records the ring holds before the oldest is dropped. */
82
+ export declare const LOG_RING_DEFAULT = 2000;
83
+ /** Longest single message or detail string kept, in characters. */
84
+ export declare const LOG_FIELD_MAX = 2000;
85
+ /**
86
+ * JSON, but it cannot throw and it cannot run away.
87
+ *
88
+ * Everything a caller might hand this is in scope: a cycle (a game object
89
+ * pointing at the state that owns it), a BigInt (JSON.stringify throws on one),
90
+ * a 40,000-element array, a function, an Error (whose message and stack are the
91
+ * only interesting parts and neither is enumerable). A logger that throws while
92
+ * describing a failure replaces the failure with its own.
93
+ */
94
+ export declare function describeValue(v: unknown, depth?: number): string;
95
+ /**
96
+ * One line of the log file.
97
+ *
98
+ * Fixed-width level so the areas line up when a human reads a thousand of them,
99
+ * and the detail after a ` | ` so the message can be found by eye without the
100
+ * JSON in the way.
101
+ */
102
+ export declare function formatLogLine(rec: LogRecord): string;
103
+ /**
104
+ * A path with the user's home directory replaced by `~`.
105
+ *
106
+ * Called on the way into a REPORT, not on the way into the log file. The file
107
+ * lives on their machine and is theirs; the report is the thing they send to a
108
+ * stranger, and `C:\Users\firstname.lastname\...` is a real name in it. The
109
+ * comparison is case-insensitive because Windows paths are, and it is a plain
110
+ * substring so it catches the home directory wherever it appears in a line -
111
+ * inside a stack trace, inside a JSON blob - not only at the start.
112
+ */
113
+ export declare function elideHome(text: string, home: string | undefined): string;
114
+ export interface LogOptions {
115
+ /** Starting level. Callers pass `defaultLogLevel(ENGINE_VERSION)`. */
116
+ readonly level: LogLevel;
117
+ /** Records held in memory. */
118
+ readonly capacity?: number;
119
+ /** Injected so a test can assert a timestamp instead of tolerating one. */
120
+ readonly now?: () => number;
121
+ }
122
+ /** What the rest of the app holds. */
123
+ export interface Log {
124
+ readonly level: LogLevel;
125
+ setLevel(level: LogLevel): void;
126
+ error(area: string, msg: string, data?: unknown): void;
127
+ warn(area: string, msg: string, data?: unknown): void;
128
+ info(area: string, msg: string, data?: unknown): void;
129
+ debug(area: string, msg: string, data?: unknown): void;
130
+ /** Add a destination. Returns the function that removes it again. */
131
+ addSink(sink: LogSink): () => void;
132
+ /** The ring, oldest first. */
133
+ recent(limit?: number): LogRecord[];
134
+ /**
135
+ * How many records the ring has thrown away.
136
+ *
137
+ * Reported on a report rather than kept quiet: "the last 2000 lines" and "the
138
+ * whole session" look identical in a text file, and the difference is whether
139
+ * the thing you are looking for could still be above the top.
140
+ */
141
+ dropped(): number;
142
+ }
143
+ export declare function createLog(opts: LogOptions): Log;
144
+ //# sourceMappingURL=log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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"}
package/dist/log.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * A log a player can hand you, and the rule that decides how much is in it.
3
+ *
4
+ * WHY THIS IS IN `core` WHEN THE ENGINE NEVER CALLS IT. It is not a parity
5
+ * module and nothing in the C corresponds to it - the honest reason is that
6
+ * `core` is the only package both the renderer and the Electron main process
7
+ * already depend on, and the alternative was two implementations of the same
8
+ * format. This project has been bitten by that shape before: two copies of a
9
+ * check, and only one of them learns. So it lives here, behind the `./log`
10
+ * subpath rather than in the barrel, next to `./host` - which is the same kind
11
+ * of thing, infrastructure the port needs and the original did not have.
12
+ *
13
+ * It does NO I/O and holds NO sinks of its own. Writing a line to a file is the
14
+ * main process's job and drawing one on a terminal is the renderer's; this
15
+ * module owns the level rule, the record, the ring, and the text - the four
16
+ * things that have to be identical on both sides of the IPC boundary or a log
17
+ * file becomes two interleaved formats.
18
+ *
19
+ * THE RING IS THE POINT, not the file. A player who hits a bug is asked for a
20
+ * report, and the report is "the last N things that happened" - which has to
21
+ * exist in memory already, because the interesting part is over by the time
22
+ * anybody presses a key.
23
+ */
24
+ /** Most severe first. Index is the rank, so the order here IS the ordering. */
25
+ export const LOG_LEVELS = ["error", "warn", "info", "debug"];
26
+ /** 0 for `error`, 3 for `debug`. -1 for anything that is not a level. */
27
+ export function logLevelRank(level) {
28
+ return LOG_LEVELS.indexOf(level);
29
+ }
30
+ export function isLogLevel(v) {
31
+ return typeof v === "string" && logLevelRank(v) >= 0;
32
+ }
33
+ /** Would a log running at `active` record something logged at `of`? */
34
+ export function logLevelAllows(active, of) {
35
+ return logLevelRank(of) <= logLevelRank(active);
36
+ }
37
+ /**
38
+ * How much this BUILD logs, decided by its own version rather than by a setting.
39
+ *
40
+ * A released build logs warnings and errors; a build somebody is testing logs
41
+ * what it is doing as well. The two states are already written down in the
42
+ * version string and nowhere else needs to agree:
43
+ *
44
+ * `0.16.1-edge.2` a per-commit build -> info
45
+ * `0.16.0` a 0.x pre-release -> info
46
+ * `1.2.3` a finished release -> warn
47
+ *
48
+ * DELIBERATELY NOT THE UPDATE CHANNEL. The channel is a preference about which
49
+ * builds to accept next, stored in localStorage and changeable at any time; a
50
+ * player who installed a beta and then picked `stable` is still running the
51
+ * beta, and the log should still say so. Asking the version means the answer
52
+ * cannot drift from the thing it describes.
53
+ *
54
+ * While the project is 0.x this returns `info` for everything, which is correct
55
+ * and temporary: `stable` selects nothing before 1.0.0 either. Both facts stop
56
+ * being true on the same day, by themselves - see `defaultChannel`, which this
57
+ * mirrors and which `update.test.ts` ties it to.
58
+ */
59
+ export function defaultLogLevel(version) {
60
+ if (version.includes("-edge."))
61
+ return "info";
62
+ return /^0\./u.test(version) ? "info" : "warn";
63
+ }
64
+ /** How many records the ring holds before the oldest is dropped. */
65
+ export const LOG_RING_DEFAULT = 2000;
66
+ /** Longest single message or detail string kept, in characters. */
67
+ export const LOG_FIELD_MAX = 2000;
68
+ /**
69
+ * JSON, but it cannot throw and it cannot run away.
70
+ *
71
+ * Everything a caller might hand this is in scope: a cycle (a game object
72
+ * pointing at the state that owns it), a BigInt (JSON.stringify throws on one),
73
+ * a 40,000-element array, a function, an Error (whose message and stack are the
74
+ * only interesting parts and neither is enumerable). A logger that throws while
75
+ * describing a failure replaces the failure with its own.
76
+ */
77
+ export function describeValue(v, depth = 4) {
78
+ const seen = new Set();
79
+ const walk = (x, left) => {
80
+ if (x === null || typeof x !== "object") {
81
+ if (typeof x === "bigint")
82
+ return `${x.toString()}n`;
83
+ if (typeof x === "function")
84
+ return `[function ${x.name || "anonymous"}]`;
85
+ if (typeof x === "symbol")
86
+ return x.toString();
87
+ if (typeof x === "string" && x.length > LOG_FIELD_MAX) {
88
+ return `${x.slice(0, LOG_FIELD_MAX)}...[${String(x.length)} chars]`;
89
+ }
90
+ return x;
91
+ }
92
+ if (seen.has(x))
93
+ return "[circular]";
94
+ if (left <= 0)
95
+ return "[...]";
96
+ if (x instanceof Error) {
97
+ /* name/message/stack are not enumerable, so the generic branch below
98
+ * would render every Error as `{}` - which is the single most useless
99
+ * thing a log can say about a failure. */
100
+ const out = { name: x.name, message: x.message };
101
+ if (typeof x.stack === "string")
102
+ out["stack"] = x.stack.split("\n").slice(0, 8).join("\n");
103
+ if (x.cause !== undefined)
104
+ out["cause"] = walk(x.cause, left - 1);
105
+ return out;
106
+ }
107
+ seen.add(x);
108
+ try {
109
+ if (Array.isArray(x)) {
110
+ const head = x.slice(0, 50).map((e) => walk(e, left - 1));
111
+ return x.length > 50 ? [...head, `...[${String(x.length)} items]`] : head;
112
+ }
113
+ const out = {};
114
+ for (const [k, val] of Object.entries(x)) {
115
+ out[k] = walk(val, left - 1);
116
+ }
117
+ return out;
118
+ }
119
+ finally {
120
+ seen.delete(x);
121
+ }
122
+ };
123
+ try {
124
+ const text = JSON.stringify(walk(v, depth));
125
+ if (text === undefined)
126
+ return String(v);
127
+ return text.length > LOG_FIELD_MAX ? `${text.slice(0, LOG_FIELD_MAX)}...` : text;
128
+ }
129
+ catch {
130
+ /* Belt and braces: a getter that throws is reachable from Object.entries. */
131
+ return "[undescribable]";
132
+ }
133
+ }
134
+ /** `2026-08-02T13:06:09.123Z` - sortable, and the same in every timezone. */
135
+ function stamp(at) {
136
+ try {
137
+ return new Date(at).toISOString();
138
+ }
139
+ catch {
140
+ /* A NaN or out-of-range timestamp must not cost the line it is attached to. */
141
+ return "????-??-??T??:??:??.???Z";
142
+ }
143
+ }
144
+ /**
145
+ * One line of the log file.
146
+ *
147
+ * Fixed-width level so the areas line up when a human reads a thousand of them,
148
+ * and the detail after a ` | ` so the message can be found by eye without the
149
+ * JSON in the way.
150
+ */
151
+ export function formatLogLine(rec) {
152
+ const head = `${stamp(rec.at)} ${rec.level.toUpperCase().padEnd(5)} [${rec.area}] ${rec.msg}`;
153
+ return rec.data === undefined ? head : `${head} | ${rec.data}`;
154
+ }
155
+ /**
156
+ * A path with the user's home directory replaced by `~`.
157
+ *
158
+ * Called on the way into a REPORT, not on the way into the log file. The file
159
+ * lives on their machine and is theirs; the report is the thing they send to a
160
+ * stranger, and `C:\Users\firstname.lastname\...` is a real name in it. The
161
+ * comparison is case-insensitive because Windows paths are, and it is a plain
162
+ * substring so it catches the home directory wherever it appears in a line -
163
+ * inside a stack trace, inside a JSON blob - not only at the start.
164
+ */
165
+ export function elideHome(text, home) {
166
+ const h = (home ?? "").replace(/[\\/]+$/u, "");
167
+ if (h.length < 4)
168
+ return text;
169
+ /*
170
+ * THREE SPELLINGS OF THE SAME PATH, and the third is the one that matters.
171
+ *
172
+ * A path reaches the log through `describeValue`, which JSON-encodes it - so
173
+ * on Windows it arrives with its backslashes DOUBLED, and neither of the two
174
+ * obvious forms matches a character of it. Checking only the raw and URL forms
175
+ * left every path this project actually logs untouched. It was caught by a
176
+ * test that used a realistic log line rather than a hand-written path, which
177
+ * is the only reason it was caught at all.
178
+ */
179
+ const forms = [
180
+ /* `{"path":"C:\\Users\\name\\..."}` - what describeValue produces. */
181
+ h.replace(/\\/gu, "\\\\"),
182
+ /* Raw, as node:path produces it. */
183
+ h,
184
+ /* A file:// URL in a stack trace, often in the same line as the above. */
185
+ h.replace(/\\/gu, "/"),
186
+ ];
187
+ let out = text;
188
+ /* Longest first, so the doubled form is taken before the single one can eat
189
+ * its leading segment and leave the rest looking like an unrelated path. */
190
+ for (const form of [...new Set(forms)].sort((a, b) => b.length - a.length)) {
191
+ const pattern = new RegExp(form.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
192
+ out = out.replace(pattern, "~");
193
+ }
194
+ return out;
195
+ }
196
+ export function createLog(opts) {
197
+ const capacity = Math.max(1, opts.capacity ?? LOG_RING_DEFAULT);
198
+ const now = opts.now ?? (() => Date.now());
199
+ const ring = new Array(capacity);
200
+ let head = 0;
201
+ let count = 0;
202
+ let lost = 0;
203
+ let level = opts.level;
204
+ const sinks = new Set();
205
+ const write = (lvl, area, msg, data) => {
206
+ if (!logLevelAllows(level, lvl))
207
+ return;
208
+ const rec = {
209
+ at: now(),
210
+ level: lvl,
211
+ area,
212
+ msg: msg.length > LOG_FIELD_MAX ? `${msg.slice(0, LOG_FIELD_MAX)}...` : msg,
213
+ ...(data === undefined ? {} : { data: describeValue(data) }),
214
+ };
215
+ if (count === capacity)
216
+ lost++;
217
+ ring[head] = rec;
218
+ head = (head + 1) % capacity;
219
+ if (count < capacity)
220
+ count++;
221
+ const line = formatLogLine(rec);
222
+ for (const sink of sinks) {
223
+ try {
224
+ sink(rec, line);
225
+ }
226
+ catch {
227
+ /* A sink that throws - a full disk, a closed IPC channel - must not
228
+ * take down the code that was only trying to say what it was doing,
229
+ * and must not stop the OTHER sinks from getting the line. */
230
+ }
231
+ }
232
+ };
233
+ return {
234
+ get level() {
235
+ return level;
236
+ },
237
+ setLevel(next) {
238
+ level = next;
239
+ },
240
+ error: (a, m, d) => {
241
+ write("error", a, m, d);
242
+ },
243
+ warn: (a, m, d) => {
244
+ write("warn", a, m, d);
245
+ },
246
+ info: (a, m, d) => {
247
+ write("info", a, m, d);
248
+ },
249
+ debug: (a, m, d) => {
250
+ write("debug", a, m, d);
251
+ },
252
+ addSink(sink) {
253
+ sinks.add(sink);
254
+ return () => {
255
+ sinks.delete(sink);
256
+ };
257
+ },
258
+ recent(limit) {
259
+ const want = Math.max(0, Math.min(limit ?? count, count));
260
+ const out = [];
261
+ for (let i = count - want; i < count; i++) {
262
+ const rec = ring[(head - count + i + capacity * 2) % capacity];
263
+ if (rec)
264
+ out.push(rec);
265
+ }
266
+ return out;
267
+ },
268
+ dropped() {
269
+ return lost;
270
+ },
271
+ };
272
+ }
273
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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"}
@@ -70,6 +70,7 @@
70
70
  * with a hook installed and asserting the level is bit-identical.
71
71
  */
72
72
  import type { GameState } from "../game/context.js";
73
+ import type { OptionStateData } from "../player/options.js";
73
74
  /**
74
75
  * A mod's behaviour contributions. Every member is optional; an absent member
75
76
  * means "this mod does not touch that point" and core takes its faithful path.
@@ -180,6 +181,31 @@ export interface ModHooks {
180
181
  * Serves: the bug-fixes mod's cosmetic string corrections.
181
182
  */
182
183
  messageText?: (raw: string) => string;
184
+ /**
185
+ * The player finished changing their options (the '=' menu closed).
186
+ *
187
+ * A NOTIFICATION, and the only one here: every other hook is asked a question
188
+ * and its answer changes what the engine does, whereas this one is told a
189
+ * thing that already happened and core does not read the return value. The
190
+ * fold is "all-observe" for that reason - there is no answer to pick between,
191
+ * so every contributor simply runs.
192
+ *
193
+ * The payload is a SNAPSHOT (OptionState.snapshot()), not the live store. A
194
+ * mod that wants to react to a setting should read what the player chose; a
195
+ * mod that wants to CHANGE a setting has state.options and does not need this.
196
+ * Handing over the live object would blur the two, and a hook that mutated the
197
+ * store while the host was mid-repaint is a bug with no obvious author.
198
+ *
199
+ * Fired by the HOST, because the option screens are the host's - core's
200
+ * OptionState is the pure port of option.c and has no idea a menu exists. The
201
+ * host's own tests pin the call sites; a screen that edits options and forgets
202
+ * to fire this is a mod silently missing an event, which is why they are
203
+ * asserted rather than trusted.
204
+ *
205
+ * Serves: the QoL mod's "remember my settings" (persisting the player's
206
+ * choices past the character they were made on).
207
+ */
208
+ optionsChanged?: (options: OptionStateData) => void;
183
209
  }
184
210
  /**
185
211
  * Fold several mods' contributions into the single ModHooks core holds.
@@ -227,7 +253,7 @@ export interface ModHooks {
227
253
  * (mod-sdk's `Fold`), so the host can hand these values straight to the conflict
228
254
  * report and the compiler checks the two agree.
229
255
  */
230
- export type ModHookFold = "all-must-agree" | "chained" | "last-answer" | "any-yes";
256
+ export type ModHookFold = "all-must-agree" | "chained" | "last-answer" | "any-yes" | "all-observe";
231
257
  /**
232
258
  * WHICH FOLD EACH HOOK USES, next to the function that implements it.
233
259
  *
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB;;;;;;;;;;;;;;;;OAgBG;IACH,qBAAqB,CAAC,EAAE,CACtB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,EAC9B,IAAI,EAAE,OAAO,KACV,MAAM,GAAG,IAAI,CAAC;IAEnB;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,CACnB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC/C,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAC5C,MAAM,CAAC;IAEZ;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3D;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,KAAK,OAAO,CAAC;IAEpE;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC;IAE/G;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAE/B;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GACnB,gBAAgB,GAChB,SAAS,GACT,aAAa,GACb,SAAS,CAAC;AAEd;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,QAAQ,EAAE,WAAW,CAAC,CAQxE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,QAAQ,CAAC;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GACrC,QAAQ,CAmEV;AAED,wBAAgB,eAAe,CAC7B,aAAa,EAAE,SAAS,QAAQ,EAAE,GACjC,QAAQ,GAAG,SAAS,CA8EtB"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAE5D;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB;;;;;;;;;;;;;;;;OAgBG;IACH,qBAAqB,CAAC,EAAE,CACtB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,EAC9B,IAAI,EAAE,OAAO,KACV,MAAM,GAAG,IAAI,CAAC;IAEnB;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,CACnB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC/C,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,KAC5C,MAAM,CAAC;IAEZ;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3D;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,KAAK,OAAO,CAAC;IAEpE;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC;IAE/G;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAE/B;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAEtC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GACnB,gBAAgB,GAChB,SAAS,GACT,aAAa,GACb,SAAS,GACT,aAAa,CAAC;AAElB;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,QAAQ,EAAE,WAAW,CAAC,CASxE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,QAAQ,CAAC;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GACrC,QAAQ,CA4EV;AAED,wBAAgB,eAAe,CAC7B,aAAa,EAAE,SAAS,QAAQ,EAAE,GACjC,QAAQ,GAAG,SAAS,CA6FtB"}
package/dist/mod/hooks.js CHANGED
@@ -93,6 +93,7 @@ export const MOD_HOOK_FOLDS = {
93
93
  historyAdd: "all-must-agree",
94
94
  saveNoiseScent: "any-yes",
95
95
  messageText: "chained",
96
+ optionsChanged: "all-observe",
96
97
  };
97
98
  /**
98
99
  * ONE mod's contribution, with every hook wrapped so a throw cannot escape into
@@ -182,6 +183,14 @@ export function guardModHooks(hooks, onFault) {
182
183
  * eat a message the player needed to read. */
183
184
  out.messageText = (raw) => guard("messageText", () => text(raw), raw);
184
185
  }
186
+ const options = hooks.optionsChanged;
187
+ if (options) {
188
+ /* Nothing to neutralise: core does not read an answer, so the neutral value
189
+ * is undefined and the guard exists only for the latch and the report. */
190
+ out.optionsChanged = (data) => {
191
+ guard("optionsChanged", () => options(data), undefined);
192
+ };
193
+ }
185
194
  return out;
186
195
  }
187
196
  export function composeModHooks(contributions) {
@@ -261,6 +270,20 @@ export function composeModHooks(contributions) {
261
270
  if (text.length > 0) {
262
271
  out.messageText = (raw) => text.reduce((s, fn) => fn(s), raw);
263
272
  }
273
+ const optionsChanged = list.map((c) => c.optionsChanged).filter(isFn);
274
+ if (optionsChanged.length > 0) {
275
+ /* LOAD order, and every one of them is told. There is no answer to choose
276
+ * between, so "later wins" has nothing to win: two mods both remembering the
277
+ * player's settings are not in conflict, they are two mods doing their own
278
+ * job with the same fact. Each gets its OWN copy - a mod that keeps the
279
+ * object it was handed and edits it later must not be editing what the next
280
+ * mod is about to read. */
281
+ out.optionsChanged = (data) => {
282
+ for (const fn of optionsChanged) {
283
+ fn({ ...data, values: { ...data.values }, birth: { ...data.birth } });
284
+ }
285
+ };
286
+ }
264
287
  return out;
265
288
  }
266
289
  function isFn(v) {
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../src/mod/hooks.ts"],"names":[],"mappings":"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"}
@@ -128,7 +128,7 @@ export type OrphanStore = Record<string, OrphanEntry[]>;
128
128
  * core pack outright, and versionMap's versions are only ever used to key
129
129
  * quarantined MOD entities. An existing save keeps whatever it recorded.
130
130
  */
131
- export declare const CORE_PACK_VERSION = "0.16.0";
131
+ export declare const CORE_PACK_VERSION = "0.18.0";
132
132
  /** A core-only manifest: the base game with no mods, deterministic. */
133
133
  export declare function coreOnlyManifest(): SaveManifest;
134
134
  /**
package/dist/version.d.ts CHANGED
@@ -21,5 +21,5 @@ export declare const PARITY_BASELINE = "4.2.6";
21
21
  * released tag is iterated takes a MINOR bump, because a published tag is pinned
22
22
  * by digest in a catalogue and must never be moved.
23
23
  */
24
- export declare const ENGINE_VERSION = "0.16.0";
24
+ export declare const ENGINE_VERSION = "0.18.0";
25
25
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -21,5 +21,5 @@ export const PARITY_BASELINE = "4.2.6";
21
21
  * released tag is iterated takes a MINOR bump, because a published tag is pinned
22
22
  * by digest in a catalogue and must never be moved.
23
23
  */
24
- export const ENGINE_VERSION = "0.16.0";
24
+ export const ENGINE_VERSION = "0.18.0";
25
25
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgm-tools/neo-angband-core",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Headless Neo Angband game engine: rules, world, entities, effects, generation",
5
5
  "license": "GPL-2.0-only",
6
6
  "author": "neostryder (RPGM Tools)",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/host/index.d.ts",
35
35
  "default": "./dist/host/index.js"
36
36
  },
37
+ "./log": {
38
+ "types": "./dist/log.d.ts",
39
+ "default": "./dist/log.js"
40
+ },
37
41
  "./package.json": "./package.json"
38
42
  },
39
43
  "//files": [
package/src/log.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * A log a player can hand you, and the rule that decides how much is in it.
3
+ *
4
+ * WHY THIS IS IN `core` WHEN THE ENGINE NEVER CALLS IT. It is not a parity
5
+ * module and nothing in the C corresponds to it - the honest reason is that
6
+ * `core` is the only package both the renderer and the Electron main process
7
+ * already depend on, and the alternative was two implementations of the same
8
+ * format. This project has been bitten by that shape before: two copies of a
9
+ * check, and only one of them learns. So it lives here, behind the `./log`
10
+ * subpath rather than in the barrel, next to `./host` - which is the same kind
11
+ * of thing, infrastructure the port needs and the original did not have.
12
+ *
13
+ * It does NO I/O and holds NO sinks of its own. Writing a line to a file is the
14
+ * main process's job and drawing one on a terminal is the renderer's; this
15
+ * module owns the level rule, the record, the ring, and the text - the four
16
+ * things that have to be identical on both sides of the IPC boundary or a log
17
+ * file becomes two interleaved formats.
18
+ *
19
+ * THE RING IS THE POINT, not the file. A player who hits a bug is asked for a
20
+ * report, and the report is "the last N things that happened" - which has to
21
+ * exist in memory already, because the interesting part is over by the time
22
+ * anybody presses a key.
23
+ */
24
+
25
+ /**
26
+ * Four levels, and no `trace`.
27
+ *
28
+ * `debug` is already the level nobody ships; a fifth below it would only ever
29
+ * be reached by editing a constant, which is what a comment is for.
30
+ */
31
+ export type LogLevel = "error" | "warn" | "info" | "debug";
32
+
33
+ /** Most severe first. Index is the rank, so the order here IS the ordering. */
34
+ export const LOG_LEVELS: readonly LogLevel[] = ["error", "warn", "info", "debug"];
35
+
36
+ /** 0 for `error`, 3 for `debug`. -1 for anything that is not a level. */
37
+ export function logLevelRank(level: string): number {
38
+ return (LOG_LEVELS as readonly string[]).indexOf(level);
39
+ }
40
+
41
+ export function isLogLevel(v: unknown): v is LogLevel {
42
+ return typeof v === "string" && logLevelRank(v) >= 0;
43
+ }
44
+
45
+ /** Would a log running at `active` record something logged at `of`? */
46
+ export function logLevelAllows(active: LogLevel, of: LogLevel): boolean {
47
+ return logLevelRank(of) <= logLevelRank(active);
48
+ }
49
+
50
+ /**
51
+ * How much this BUILD logs, decided by its own version rather than by a setting.
52
+ *
53
+ * A released build logs warnings and errors; a build somebody is testing logs
54
+ * what it is doing as well. The two states are already written down in the
55
+ * version string and nowhere else needs to agree:
56
+ *
57
+ * `0.16.1-edge.2` a per-commit build -> info
58
+ * `0.16.0` a 0.x pre-release -> info
59
+ * `1.2.3` a finished release -> warn
60
+ *
61
+ * DELIBERATELY NOT THE UPDATE CHANNEL. The channel is a preference about which
62
+ * builds to accept next, stored in localStorage and changeable at any time; a
63
+ * player who installed a beta and then picked `stable` is still running the
64
+ * beta, and the log should still say so. Asking the version means the answer
65
+ * cannot drift from the thing it describes.
66
+ *
67
+ * While the project is 0.x this returns `info` for everything, which is correct
68
+ * and temporary: `stable` selects nothing before 1.0.0 either. Both facts stop
69
+ * being true on the same day, by themselves - see `defaultChannel`, which this
70
+ * mirrors and which `update.test.ts` ties it to.
71
+ */
72
+ export function defaultLogLevel(version: string): LogLevel {
73
+ if (version.includes("-edge.")) return "info";
74
+ return /^0\./u.test(version) ? "info" : "warn";
75
+ }
76
+
77
+ /**
78
+ * One line of log, with its detail ALREADY rendered to text.
79
+ *
80
+ * Serialising at record time rather than at write time is what bounds the ring:
81
+ * a record holds a string, not a reference to a live game object that will have
82
+ * changed - or been mutated into a cycle - by the time anybody reads it. It also
83
+ * means a report cannot be the thing that throws while somebody is filing a bug.
84
+ */
85
+ export interface LogRecord {
86
+ /** Epoch milliseconds. */
87
+ readonly at: number;
88
+ readonly level: LogLevel;
89
+ /** Where it came from: `update`, `mods`, `save`. Short and stable. */
90
+ readonly area: string;
91
+ readonly msg: string;
92
+ /** The extra detail, JSON-ish, or undefined when there was none. */
93
+ readonly data?: string;
94
+ }
95
+
96
+ /** Somewhere a line goes. Given the record and the text, so it need not reformat. */
97
+ export type LogSink = (rec: LogRecord, line: string) => void;
98
+
99
+ /** How many records the ring holds before the oldest is dropped. */
100
+ export const LOG_RING_DEFAULT = 2000;
101
+
102
+ /** Longest single message or detail string kept, in characters. */
103
+ export const LOG_FIELD_MAX = 2000;
104
+
105
+ /**
106
+ * JSON, but it cannot throw and it cannot run away.
107
+ *
108
+ * Everything a caller might hand this is in scope: a cycle (a game object
109
+ * pointing at the state that owns it), a BigInt (JSON.stringify throws on one),
110
+ * a 40,000-element array, a function, an Error (whose message and stack are the
111
+ * only interesting parts and neither is enumerable). A logger that throws while
112
+ * describing a failure replaces the failure with its own.
113
+ */
114
+ export function describeValue(v: unknown, depth = 4): string {
115
+ const seen = new Set<unknown>();
116
+ const walk = (x: unknown, left: number): unknown => {
117
+ if (x === null || typeof x !== "object") {
118
+ if (typeof x === "bigint") return `${x.toString()}n`;
119
+ if (typeof x === "function") return `[function ${x.name || "anonymous"}]`;
120
+ if (typeof x === "symbol") return x.toString();
121
+ if (typeof x === "string" && x.length > LOG_FIELD_MAX) {
122
+ return `${x.slice(0, LOG_FIELD_MAX)}...[${String(x.length)} chars]`;
123
+ }
124
+ return x;
125
+ }
126
+ if (seen.has(x)) return "[circular]";
127
+ if (left <= 0) return "[...]";
128
+ if (x instanceof Error) {
129
+ /* name/message/stack are not enumerable, so the generic branch below
130
+ * would render every Error as `{}` - which is the single most useless
131
+ * thing a log can say about a failure. */
132
+ const out: Record<string, unknown> = { name: x.name, message: x.message };
133
+ if (typeof x.stack === "string") out["stack"] = x.stack.split("\n").slice(0, 8).join("\n");
134
+ if (x.cause !== undefined) out["cause"] = walk(x.cause, left - 1);
135
+ return out;
136
+ }
137
+ seen.add(x);
138
+ try {
139
+ if (Array.isArray(x)) {
140
+ const head = x.slice(0, 50).map((e) => walk(e, left - 1));
141
+ return x.length > 50 ? [...head, `...[${String(x.length)} items]`] : head;
142
+ }
143
+ const out: Record<string, unknown> = {};
144
+ for (const [k, val] of Object.entries(x as Record<string, unknown>)) {
145
+ out[k] = walk(val, left - 1);
146
+ }
147
+ return out;
148
+ } finally {
149
+ seen.delete(x);
150
+ }
151
+ };
152
+ try {
153
+ const text = JSON.stringify(walk(v, depth));
154
+ if (text === undefined) return String(v);
155
+ return text.length > LOG_FIELD_MAX ? `${text.slice(0, LOG_FIELD_MAX)}...` : text;
156
+ } catch {
157
+ /* Belt and braces: a getter that throws is reachable from Object.entries. */
158
+ return "[undescribable]";
159
+ }
160
+ }
161
+
162
+ /** `2026-08-02T13:06:09.123Z` - sortable, and the same in every timezone. */
163
+ function stamp(at: number): string {
164
+ try {
165
+ return new Date(at).toISOString();
166
+ } catch {
167
+ /* A NaN or out-of-range timestamp must not cost the line it is attached to. */
168
+ return "????-??-??T??:??:??.???Z";
169
+ }
170
+ }
171
+
172
+ /**
173
+ * One line of the log file.
174
+ *
175
+ * Fixed-width level so the areas line up when a human reads a thousand of them,
176
+ * and the detail after a ` | ` so the message can be found by eye without the
177
+ * JSON in the way.
178
+ */
179
+ export function formatLogLine(rec: LogRecord): string {
180
+ const head = `${stamp(rec.at)} ${rec.level.toUpperCase().padEnd(5)} [${rec.area}] ${rec.msg}`;
181
+ return rec.data === undefined ? head : `${head} | ${rec.data}`;
182
+ }
183
+
184
+ /**
185
+ * A path with the user's home directory replaced by `~`.
186
+ *
187
+ * Called on the way into a REPORT, not on the way into the log file. The file
188
+ * lives on their machine and is theirs; the report is the thing they send to a
189
+ * stranger, and `C:\Users\firstname.lastname\...` is a real name in it. The
190
+ * comparison is case-insensitive because Windows paths are, and it is a plain
191
+ * substring so it catches the home directory wherever it appears in a line -
192
+ * inside a stack trace, inside a JSON blob - not only at the start.
193
+ */
194
+ export function elideHome(text: string, home: string | undefined): string {
195
+ const h = (home ?? "").replace(/[\\/]+$/u, "");
196
+ if (h.length < 4) return text;
197
+ /*
198
+ * THREE SPELLINGS OF THE SAME PATH, and the third is the one that matters.
199
+ *
200
+ * A path reaches the log through `describeValue`, which JSON-encodes it - so
201
+ * on Windows it arrives with its backslashes DOUBLED, and neither of the two
202
+ * obvious forms matches a character of it. Checking only the raw and URL forms
203
+ * left every path this project actually logs untouched. It was caught by a
204
+ * test that used a realistic log line rather than a hand-written path, which
205
+ * is the only reason it was caught at all.
206
+ */
207
+ const forms = [
208
+ /* `{"path":"C:\\Users\\name\\..."}` - what describeValue produces. */
209
+ h.replace(/\\/gu, "\\\\"),
210
+ /* Raw, as node:path produces it. */
211
+ h,
212
+ /* A file:// URL in a stack trace, often in the same line as the above. */
213
+ h.replace(/\\/gu, "/"),
214
+ ];
215
+ let out = text;
216
+ /* Longest first, so the doubled form is taken before the single one can eat
217
+ * its leading segment and leave the rest looking like an unrelated path. */
218
+ for (const form of [...new Set(forms)].sort((a, b) => b.length - a.length)) {
219
+ const pattern = new RegExp(form.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
220
+ out = out.replace(pattern, "~");
221
+ }
222
+ return out;
223
+ }
224
+
225
+ export interface LogOptions {
226
+ /** Starting level. Callers pass `defaultLogLevel(ENGINE_VERSION)`. */
227
+ readonly level: LogLevel;
228
+ /** Records held in memory. */
229
+ readonly capacity?: number;
230
+ /** Injected so a test can assert a timestamp instead of tolerating one. */
231
+ readonly now?: () => number;
232
+ }
233
+
234
+ /** What the rest of the app holds. */
235
+ export interface Log {
236
+ readonly level: LogLevel;
237
+ setLevel(level: LogLevel): void;
238
+ error(area: string, msg: string, data?: unknown): void;
239
+ warn(area: string, msg: string, data?: unknown): void;
240
+ info(area: string, msg: string, data?: unknown): void;
241
+ debug(area: string, msg: string, data?: unknown): void;
242
+ /** Add a destination. Returns the function that removes it again. */
243
+ addSink(sink: LogSink): () => void;
244
+ /** The ring, oldest first. */
245
+ recent(limit?: number): LogRecord[];
246
+ /**
247
+ * How many records the ring has thrown away.
248
+ *
249
+ * Reported on a report rather than kept quiet: "the last 2000 lines" and "the
250
+ * whole session" look identical in a text file, and the difference is whether
251
+ * the thing you are looking for could still be above the top.
252
+ */
253
+ dropped(): number;
254
+ }
255
+
256
+ export function createLog(opts: LogOptions): Log {
257
+ const capacity = Math.max(1, opts.capacity ?? LOG_RING_DEFAULT);
258
+ const now = opts.now ?? ((): number => Date.now());
259
+ const ring: (LogRecord | undefined)[] = new Array<LogRecord | undefined>(capacity);
260
+ let head = 0;
261
+ let count = 0;
262
+ let lost = 0;
263
+ let level = opts.level;
264
+ const sinks = new Set<LogSink>();
265
+
266
+ const write = (lvl: LogLevel, area: string, msg: string, data: unknown): void => {
267
+ if (!logLevelAllows(level, lvl)) return;
268
+ const rec: LogRecord = {
269
+ at: now(),
270
+ level: lvl,
271
+ area,
272
+ msg: msg.length > LOG_FIELD_MAX ? `${msg.slice(0, LOG_FIELD_MAX)}...` : msg,
273
+ ...(data === undefined ? {} : { data: describeValue(data) }),
274
+ };
275
+ if (count === capacity) lost++;
276
+ ring[head] = rec;
277
+ head = (head + 1) % capacity;
278
+ if (count < capacity) count++;
279
+ const line = formatLogLine(rec);
280
+ for (const sink of sinks) {
281
+ try {
282
+ sink(rec, line);
283
+ } catch {
284
+ /* A sink that throws - a full disk, a closed IPC channel - must not
285
+ * take down the code that was only trying to say what it was doing,
286
+ * and must not stop the OTHER sinks from getting the line. */
287
+ }
288
+ }
289
+ };
290
+
291
+ return {
292
+ get level(): LogLevel {
293
+ return level;
294
+ },
295
+ setLevel(next: LogLevel): void {
296
+ level = next;
297
+ },
298
+ error: (a, m, d?) => {
299
+ write("error", a, m, d);
300
+ },
301
+ warn: (a, m, d?) => {
302
+ write("warn", a, m, d);
303
+ },
304
+ info: (a, m, d?) => {
305
+ write("info", a, m, d);
306
+ },
307
+ debug: (a, m, d?) => {
308
+ write("debug", a, m, d);
309
+ },
310
+ addSink(sink: LogSink): () => void {
311
+ sinks.add(sink);
312
+ return () => {
313
+ sinks.delete(sink);
314
+ };
315
+ },
316
+ recent(limit?: number): LogRecord[] {
317
+ const want = Math.max(0, Math.min(limit ?? count, count));
318
+ const out: LogRecord[] = [];
319
+ for (let i = count - want; i < count; i++) {
320
+ const rec = ring[(head - count + i + capacity * 2) % capacity];
321
+ if (rec) out.push(rec);
322
+ }
323
+ return out;
324
+ },
325
+ dropped(): number {
326
+ return lost;
327
+ },
328
+ };
329
+ }
package/src/mod/hooks.ts CHANGED
@@ -71,6 +71,7 @@
71
71
  */
72
72
 
73
73
  import type { GameState } from "../game/context.js";
74
+ import type { OptionStateData } from "../player/options.js";
74
75
 
75
76
  /**
76
77
  * A mod's behaviour contributions. Every member is optional; an absent member
@@ -182,6 +183,32 @@ export interface ModHooks {
182
183
  * Serves: the bug-fixes mod's cosmetic string corrections.
183
184
  */
184
185
  messageText?: (raw: string) => string;
186
+
187
+ /**
188
+ * The player finished changing their options (the '=' menu closed).
189
+ *
190
+ * A NOTIFICATION, and the only one here: every other hook is asked a question
191
+ * and its answer changes what the engine does, whereas this one is told a
192
+ * thing that already happened and core does not read the return value. The
193
+ * fold is "all-observe" for that reason - there is no answer to pick between,
194
+ * so every contributor simply runs.
195
+ *
196
+ * The payload is a SNAPSHOT (OptionState.snapshot()), not the live store. A
197
+ * mod that wants to react to a setting should read what the player chose; a
198
+ * mod that wants to CHANGE a setting has state.options and does not need this.
199
+ * Handing over the live object would blur the two, and a hook that mutated the
200
+ * store while the host was mid-repaint is a bug with no obvious author.
201
+ *
202
+ * Fired by the HOST, because the option screens are the host's - core's
203
+ * OptionState is the pure port of option.c and has no idea a menu exists. The
204
+ * host's own tests pin the call sites; a screen that edits options and forgets
205
+ * to fire this is a mod silently missing an event, which is why they are
206
+ * asserted rather than trusted.
207
+ *
208
+ * Serves: the QoL mod's "remember my settings" (persisting the player's
209
+ * choices past the character they were made on).
210
+ */
211
+ optionsChanged?: (options: OptionStateData) => void;
185
212
  }
186
213
 
187
214
  /**
@@ -234,7 +261,8 @@ export type ModHookFold =
234
261
  | "all-must-agree" // every contributor runs; the first refusal decides
235
262
  | "chained" // each sees the previous one's output, so the last one speaks last
236
263
  | "last-answer" // the LAST contributor with an opinion decides; earlier ones are not asked
237
- | "any-yes"; // one contributor asking for it is enough
264
+ | "any-yes" // one contributor asking for it is enough
265
+ | "all-observe"; // a notification: every contributor is told, and none of them answers
238
266
 
239
267
  /**
240
268
  * WHICH FOLD EACH HOOK USES, next to the function that implements it.
@@ -260,6 +288,7 @@ export const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>> = {
260
288
  historyAdd: "all-must-agree",
261
289
  saveNoiseScent: "any-yes",
262
290
  messageText: "chained",
291
+ optionsChanged: "all-observe",
263
292
  };
264
293
 
265
294
  /**
@@ -378,6 +407,15 @@ export function guardModHooks(
378
407
  out.messageText = (raw): string => guard("messageText", () => text(raw), raw);
379
408
  }
380
409
 
410
+ const options = hooks.optionsChanged;
411
+ if (options) {
412
+ /* Nothing to neutralise: core does not read an answer, so the neutral value
413
+ * is undefined and the guard exists only for the latch and the report. */
414
+ out.optionsChanged = (data): void => {
415
+ guard("optionsChanged", () => options(data), undefined);
416
+ };
417
+ }
418
+
381
419
  return out;
382
420
  }
383
421
 
@@ -460,6 +498,21 @@ export function composeModHooks(
460
498
  out.messageText = (raw): string => text.reduce((s, fn) => fn(s), raw);
461
499
  }
462
500
 
501
+ const optionsChanged = list.map((c) => c.optionsChanged).filter(isFn);
502
+ if (optionsChanged.length > 0) {
503
+ /* LOAD order, and every one of them is told. There is no answer to choose
504
+ * between, so "later wins" has nothing to win: two mods both remembering the
505
+ * player's settings are not in conflict, they are two mods doing their own
506
+ * job with the same fact. Each gets its OWN copy - a mod that keeps the
507
+ * object it was handed and edits it later must not be editing what the next
508
+ * mod is about to read. */
509
+ out.optionsChanged = (data): void => {
510
+ for (const fn of optionsChanged) {
511
+ fn({ ...data, values: { ...data.values }, birth: { ...data.birth } });
512
+ }
513
+ };
514
+ }
515
+
463
516
  return out;
464
517
  }
465
518
 
package/src/version.ts CHANGED
@@ -23,4 +23,4 @@ export const PARITY_BASELINE = "4.2.6";
23
23
  * released tag is iterated takes a MINOR bump, because a published tag is pinned
24
24
  * by digest in a catalogue and must never be moved.
25
25
  */
26
- export const ENGINE_VERSION = "0.16.0";
26
+ export const ENGINE_VERSION = "0.18.0";