@rpgm-tools/neo-angband-core 1.8.0 → 1.9.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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The stash MODEL: what the orphans store looks like to a player
3
+ * (MOD_LIFECYCLE.md decisions 7 and 8).
4
+ *
5
+ * `save-blocks.ts` owns the storage half. An entity whose defining pack is
6
+ * absent is frozen verbatim into `orphans:<id>@<version>` with its content id
7
+ * and its group / equipment-slot relationships intact, and reinstalling the
8
+ * pack rehydrates it exactly where it was. That half is complete, tested, and
9
+ * invisible: a player who uninstalls a mod watches their items disappear from
10
+ * the pack with nothing anywhere saying where they went.
11
+ *
12
+ * This module is the other half's DATA. It reads the store and answers the
13
+ * three questions decision 7's stash view asks - what is set aside, which mod
14
+ * owned it, and what would bring it back - as a structure a front end renders.
15
+ * The wording is deliberately NOT here: a screen owns its own sentences, the
16
+ * same split `screens.ts` keeps against the core's UI models.
17
+ *
18
+ * READ-ONLY, AND THAT IS THE DESIGN. Nothing in this file writes to a store or
19
+ * to a save. `purgeOrphans` is the single exception decision 8 requires, and it
20
+ * is a pure function that returns an empty store rather than editing one, so
21
+ * the caller's own assignment is the only mutation and it happens in one place.
22
+ *
23
+ * WHAT IT CANNOT SAY. Quarantine is keyed on namespace PRESENCE, so the store
24
+ * records that `frost` was missing and never why. This module therefore derives
25
+ * the reason from what the host can see NOW - the pack is not installed, or it
26
+ * is installed and switched off - rather than reading a reason the store does
27
+ * not carry. A caller that knows neither gets `"unknown"`, which is honest,
28
+ * where guessing "uninstalled" would not be. The SHADOWED case (a later mod's
29
+ * `removes`/`replaces` overriding a record the save depends on) reaches the same
30
+ * store by the same route the moment quarantine learns to produce it; it needs
31
+ * nothing here, because a shadowed entity's namespace is present and this
32
+ * already has a state for that.
33
+ */
34
+
35
+ import { namespaceOf, orphanCount } from "./save-blocks.js";
36
+ import type { OrphanEntry, OrphanKind, OrphanStore } from "./save-blocks.js";
37
+
38
+ /* ------------------------------------------------------------------ *
39
+ * Categories: what a quarantined entity WAS, in the player's terms.
40
+ * ------------------------------------------------------------------ */
41
+
42
+ /**
43
+ * What one quarantined entity is, as a player would name it.
44
+ *
45
+ * A category rather than a re-spelling of `OrphanKind`, because two kinds can
46
+ * be the same thing to a player (`gearObject` in an equipment slot and one in
47
+ * the pack differ only in where they were) and one kind can be two things
48
+ * (`gearObject` is exactly that case). `link` is the one that is NOT a thing
49
+ * the player owns: it is the group bookkeeping quarantine keeps so a mod's
50
+ * monster pack returns as a pack, and a screen that listed those as items would
51
+ * be showing a player a row per internal relationship.
52
+ */
53
+ export type OrphanCategory =
54
+ | "worn"
55
+ | "carried"
56
+ | "floor"
57
+ | "held"
58
+ | "monster"
59
+ | "trap"
60
+ | "lore"
61
+ | "artifact"
62
+ | "cached"
63
+ | "link";
64
+
65
+ /** Whether a `gearObject` orphan was quarantined out of an equipment slot. */
66
+ function wasWorn(entry: OrphanEntry): boolean {
67
+ const locus = entry.locus;
68
+ if (typeof locus !== "object" || locus === null || Array.isArray(locus)) return false;
69
+ const slots = (locus as { equipSlots?: unknown }).equipSlots;
70
+ return Array.isArray(slots) && slots.length > 0;
71
+ }
72
+
73
+ /** The player-facing category of one quarantined entity. */
74
+ export function orphanCategory(entry: OrphanEntry): OrphanCategory {
75
+ switch (entry.kind) {
76
+ case "gearObject":
77
+ return wasWorn(entry) ? "worn" : "carried";
78
+ case "heldObject":
79
+ return "held";
80
+ case "floorObject":
81
+ return "floor";
82
+ case "monster":
83
+ return "monster";
84
+ case "trap":
85
+ return "trap";
86
+ case "lore":
87
+ return "lore";
88
+ case "artifactCreated":
89
+ return "artifact";
90
+ /* The frozen-level cache (birth_levels_persist): a level the player has
91
+ * been to and can return to, which is one fact rather than four. */
92
+ case "cacheMonster":
93
+ case "cacheHeldObject":
94
+ case "cacheFloorObject":
95
+ case "cacheTrap":
96
+ return "cached";
97
+ case "group":
98
+ case "groupMembership":
99
+ case "cacheGroup":
100
+ case "cacheGroupMembership":
101
+ return "link";
102
+ }
103
+ }
104
+
105
+ /* ------------------------------------------------------------------ *
106
+ * The model.
107
+ * ------------------------------------------------------------------ */
108
+
109
+ /** One quarantined entity, as the stash view lists it. */
110
+ export interface OrphanStashItem {
111
+ /** The storage kind, for a presenter that wants the exact collection. */
112
+ readonly kind: OrphanKind;
113
+ /** What it is to a player. See `OrphanCategory`. */
114
+ readonly category: OrphanCategory;
115
+ /** The content id that could not resolve, e.g. `frost:ice-brand`. */
116
+ readonly ref: string;
117
+ /**
118
+ * The id with its namespace stripped, e.g. `ice-brand`.
119
+ *
120
+ * The BEST NAME THERE IS, and worth saying why it is not a real one. A frozen
121
+ * object carries its `kindId` and nothing else - the record that would give it
122
+ * a printable name came from the mod, which is the thing that is missing - so
123
+ * `describeObject` has nothing to work from. The id is what the player has,
124
+ * and it is what `OrphanEntry.ref` was documented to be for.
125
+ */
126
+ readonly name: string;
127
+ }
128
+
129
+ /**
130
+ * Whether the mod that owns a group of orphans can be found on this machine.
131
+ *
132
+ * `absent` and `disabled` are different sentences to a player: one needs a
133
+ * download and the other needs a switch. `present` means the pack composed and
134
+ * the entity still could not be put back, which `rehydrateSave` reports by
135
+ * leaving the entry in the store rather than by throwing.
136
+ */
137
+ export type OrphanAvailability = "absent" | "disabled" | "present" | "unknown";
138
+
139
+ /** Everything quarantined under one pack, at the version the save used. */
140
+ export interface OrphanStashGroup {
141
+ /** The store's own key, `<namespace>@<version>`. */
142
+ readonly key: string;
143
+ /** The pack that owned this content, e.g. `frost`. */
144
+ readonly namespace: string;
145
+ /** The version that pack was at when the save was written. */
146
+ readonly version: string;
147
+ /** What the host can see of that pack right now. */
148
+ readonly availability: OrphanAvailability;
149
+ /** The entities, in store order (which is quarantine order). */
150
+ readonly items: readonly OrphanStashItem[];
151
+ /**
152
+ * How many `link` entries this group carries, counted rather than listed.
153
+ *
154
+ * Counted BECAUSE the count is the honest number and the rows are not: these
155
+ * are monster-group relationships, one per member of every quarantined pack,
156
+ * so a mod with a dozen monsters in groups would fill the screen with rows a
157
+ * player cannot act on. `total` below is what decision 8's prompt counts, and
158
+ * it includes these, so the screen and the prompt cannot disagree.
159
+ */
160
+ readonly links: number;
161
+ /** `items.length + links` - every entry under this key. */
162
+ readonly total: number;
163
+ }
164
+
165
+ /** The whole stash: every pack with something quarantined under it. */
166
+ export interface OrphanStash {
167
+ /** Groups sorted by namespace then version, so the screen is stable. */
168
+ readonly groups: readonly OrphanStashGroup[];
169
+ /** Every entry in the store, matching `orphanCount`. */
170
+ readonly total: number;
171
+ }
172
+
173
+ /** What the host knows about the packs it can see. All optional. */
174
+ export interface OrphanStashDeps {
175
+ /** Namespaces whose content composed into the running game. */
176
+ readonly present?: ReadonlySet<string>;
177
+ /** Namespaces installed on this machine, whether or not they are enabled. */
178
+ readonly installed?: ReadonlySet<string>;
179
+ }
180
+
181
+ /**
182
+ * Split a store key back into the namespace and version that made it.
183
+ *
184
+ * The FIRST `@` is the separator, which is what `rehydrateSave` already treats
185
+ * as the separator when it decides whether a key's pack is present. A namespace
186
+ * cannot contain one, so the two can only disagree on a version that does, and
187
+ * disagreeing there would mean this screen naming a pack that rehydrate would
188
+ * never match.
189
+ */
190
+ export function parseOrphanKey(key: string): { namespace: string; version: string } {
191
+ const at = key.indexOf("@");
192
+ if (at <= 0) return { namespace: key, version: "" };
193
+ return { namespace: key.slice(0, at), version: key.slice(at + 1) };
194
+ }
195
+
196
+ function availabilityOf(namespace: string, deps: OrphanStashDeps): OrphanAvailability {
197
+ const { present, installed } = deps;
198
+ if (present?.has(namespace)) return "present";
199
+ if (installed?.has(namespace)) return "disabled";
200
+ /* Absent is a claim, so it needs evidence: a caller that supplied neither set
201
+ * knows nothing about this pack and must not be reported as having looked. */
202
+ if (present === undefined && installed === undefined) return "unknown";
203
+ return "absent";
204
+ }
205
+
206
+ function itemOf(entry: OrphanEntry): OrphanStashItem {
207
+ const namespace = namespaceOf(entry.ref);
208
+ return {
209
+ kind: entry.kind,
210
+ category: orphanCategory(entry),
211
+ ref: entry.ref,
212
+ name:
213
+ namespace !== null && entry.ref.startsWith(`${namespace}:`)
214
+ ? entry.ref.slice(namespace.length + 1)
215
+ : entry.ref,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * The stash view's model: every quarantined entity, grouped by the pack that
221
+ * owned it, with what the host can currently see of that pack.
222
+ *
223
+ * Pure and total. An empty or absent store gives an empty stash rather than
224
+ * null, so a caller renders "nothing is set aside" from the same value it
225
+ * renders a full list from.
226
+ */
227
+ export function orphanStash(
228
+ store: OrphanStore | undefined,
229
+ deps: OrphanStashDeps = {},
230
+ ): OrphanStash {
231
+ const groups: OrphanStashGroup[] = [];
232
+ for (const [key, entries] of Object.entries(store ?? {})) {
233
+ const { namespace, version } = parseOrphanKey(key);
234
+ const items: OrphanStashItem[] = [];
235
+ let links = 0;
236
+ for (const entry of entries) {
237
+ if (orphanCategory(entry) === "link") links++;
238
+ else items.push(itemOf(entry));
239
+ }
240
+ groups.push({
241
+ key,
242
+ namespace,
243
+ version,
244
+ availability: availabilityOf(namespace, deps),
245
+ items,
246
+ links,
247
+ total: items.length + links,
248
+ });
249
+ }
250
+ groups.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.version.localeCompare(b.version));
251
+ return { groups, total: orphanCount(store) };
252
+ }
253
+
254
+ /* ------------------------------------------------------------------ *
255
+ * Decision 8: the one-time keep/purge question.
256
+ * ------------------------------------------------------------------ */
257
+
258
+ /**
259
+ * Whether the one-time per-save keep/purge prompt is due (decision 8).
260
+ *
261
+ * Due exactly once: something is quarantined and this save has not been asked
262
+ * yet. Keeping is the default and answering either way sets `acknowledged`, so
263
+ * a player who declines is never nagged and a save that later quarantines more
264
+ * content is not re-asked - which is what "one-time per-save" means.
265
+ */
266
+ export function orphanPromptDue(
267
+ store: OrphanStore | undefined,
268
+ acknowledged: boolean,
269
+ ): boolean {
270
+ return !acknowledged && orphanCount(store) > 0;
271
+ }
272
+
273
+ /**
274
+ * Decision 8's purge: every quarantined entity, gone permanently.
275
+ *
276
+ * A pure function returning the EMPTY store rather than one that empties the
277
+ * caller's, so the only write is the caller's own assignment. That matters more
278
+ * than it looks: this is the one destructive act in the whole orphan path, and
279
+ * the guarantee it breaks - nothing a player earned vanishes without a trace -
280
+ * is only allowed to break behind an explicit, counted, one-time confirmation.
281
+ * Quarantine remains the default; nothing calls this without an answer.
282
+ */
283
+ export function purgeOrphans(): OrphanStore {
284
+ return {};
285
+ }
@@ -52,9 +52,14 @@
52
52
  * separate hard-incompatibility concern, not a quarantine case. Finer
53
53
  * sub-property granularity (a mod ego or brand on an otherwise-core object, a
54
54
  * mod origin-race on a core object) degrades to the base entity and is a
55
- * documented follow-up. The player-facing recoveries built ON TOP of this store
56
- * (stranded characters returning to town, mod items surfaced in the home, the
57
- * stash view) are P-UI work per MOD_LIFECYCLE section 6 step 2.
55
+ * documented follow-up. Of the player-facing recoveries built ON TOP of this
56
+ * store (MOD_LIFECYCLE decision 7), the stash view and the one-time keep/purge
57
+ * question are built - `mod/orphan-stash.ts` is the read-only model and
58
+ * `packages/web/src/mod-orphans.ts` the screen - and a quarantined item is
59
+ * surfaced there rather than as home stock, because the exact restore this file
60
+ * performs needs the gear handle and equipment slots that only the store
61
+ * carries. Returning a stranded character to town is still P-UI work per
62
+ * MOD_LIFECYCLE section 6 step 2.
58
63
  */
59
64
 
60
65
  import { parseId } from "./ids.js";
@@ -525,6 +525,20 @@ export interface StartedGame {
525
525
  orphans: OrphanStore;
526
526
  /** decision-8: whether the one-time orphan keep/purge prompt has been shown. */
527
527
  orphansAcknowledged: boolean;
528
+ /**
529
+ * How many entities THIS load newly quarantined, as opposed to how many the
530
+ * store holds (`orphanCount(orphans)` answers that).
531
+ *
532
+ * The difference is the whole value of the number. A save reloaded with the
533
+ * same mod still missing quarantines nothing new - the entities were pruned
534
+ * out of the live collections on the first load and written into the store -
535
+ * so this is non-zero exactly on the load where the loss actually happened.
536
+ * That is the load a host should say something on (MOD_LIFECYCLE decision 7's
537
+ * "nothing a player earned vanishes without a trace"); saying it on every
538
+ * subsequent boot would be a nag, and saying it on none of them is the silence
539
+ * the stash view exists to end. 0 on a new game and on a clean reload.
540
+ */
541
+ quarantined: number;
528
542
  /**
529
543
  * Namespaces present now whose recorded content hash no longer matches
530
544
  * their current one (issue #20, save-blocks.ts mismatchedNamespaces): a
@@ -3856,6 +3870,7 @@ export function startGame(pack: GamePack, opts: StartGameOptions = {}): StartedG
3856
3870
  mods: {},
3857
3871
  orphans: {},
3858
3872
  orphansAcknowledged: false,
3873
+ quarantined: 0,
3859
3874
  mismatchedPacks: [],
3860
3875
  options,
3861
3876
  randartSeed,
@@ -4778,6 +4793,7 @@ export function loadGame(
4778
4793
  mods: save.mods ?? {},
4779
4794
  orphans: quarantine.orphans,
4780
4795
  orphansAcknowledged: save.orphansAcknowledged ?? false,
4796
+ quarantined: quarantine.quarantined,
4781
4797
  mismatchedPacks,
4782
4798
  ...(migration.applied.length > 0 || migration.notes.length > 0
4783
4799
  ? { saveMigration: { applied: migration.applied, notes: migration.notes } }
package/src/version.ts CHANGED
@@ -24,4 +24,4 @@ export const PARITY_BASELINE = "4.2.6";
24
24
  * warrants, because a published tag is pinned by digest in a catalogue and
25
25
  * must never be moved.
26
26
  */
27
- export const ENGINE_VERSION = "1.8.0";
27
+ export const ENGINE_VERSION = "1.9.0";