@rpgm-tools/neo-angband-core 1.3.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.
Files changed (52) hide show
  1. package/README.md +7 -7
  2. package/dist/game/obj-cmd.d.ts.map +1 -1
  3. package/dist/game/obj-cmd.js +16 -3
  4. package/dist/game/obj-cmd.js.map +1 -1
  5. package/dist/game/pickup.d.ts.map +1 -1
  6. package/dist/game/pickup.js +17 -1
  7. package/dist/game/pickup.js.map +1 -1
  8. package/dist/game/player-turn.d.ts +2 -0
  9. package/dist/game/player-turn.d.ts.map +1 -1
  10. package/dist/game/player-turn.js +4 -0
  11. package/dist/game/player-turn.js.map +1 -1
  12. package/dist/game/spell-cmd.d.ts.map +1 -1
  13. package/dist/game/spell-cmd.js +10 -0
  14. package/dist/game/spell-cmd.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +4 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/mod/hooks.d.ts +25 -1
  20. package/dist/mod/hooks.d.ts.map +1 -1
  21. package/dist/mod/hooks.js +14 -0
  22. package/dist/mod/hooks.js.map +1 -1
  23. package/dist/mod/orphan-stash.d.ts +158 -0
  24. package/dist/mod/orphan-stash.d.ts.map +1 -0
  25. package/dist/mod/orphan-stash.js +172 -0
  26. package/dist/mod/orphan-stash.js.map +1 -0
  27. package/dist/mod/registry-host.d.ts +14 -0
  28. package/dist/mod/registry-host.d.ts.map +1 -1
  29. package/dist/mod/registry-host.js +8 -0
  30. package/dist/mod/registry-host.js.map +1 -1
  31. package/dist/mod/save-blocks.d.ts +9 -4
  32. package/dist/mod/save-blocks.d.ts.map +1 -1
  33. package/dist/mod/save-blocks.js +8 -3
  34. package/dist/mod/save-blocks.js.map +1 -1
  35. package/dist/session/game.d.ts +14 -0
  36. package/dist/session/game.d.ts.map +1 -1
  37. package/dist/session/game.js +2 -0
  38. package/dist/session/game.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +1 -1
  42. package/src/game/obj-cmd.ts +17 -3
  43. package/src/game/pickup.ts +20 -0
  44. package/src/game/player-turn.ts +5 -0
  45. package/src/game/spell-cmd.ts +10 -0
  46. package/src/index.ts +4 -0
  47. package/src/mod/hooks.ts +44 -1
  48. package/src/mod/orphan-stash.ts +285 -0
  49. package/src/mod/registry-host.ts +23 -0
  50. package/src/mod/save-blocks.ts +8 -3
  51. package/src/session/game.ts +16 -0
  52. package/src/version.ts +1 -1
@@ -116,6 +116,11 @@ export class ActionRegistry {
116
116
  this.actions.set(code, action);
117
117
  }
118
118
 
119
+ /** Remove an API-2 declaration during its host-owned teardown. */
120
+ unregister(code: string): void {
121
+ this.actions.delete(code);
122
+ }
123
+
119
124
  has(code: string): boolean {
120
125
  return this.actions.has(code);
121
126
  }
@@ -431,6 +431,16 @@ export function installSpellCommands(
431
431
  }
432
432
 
433
433
  spellLearn(player, spellIndex, env.msg);
434
+ const learned = spellByIndex(player.cls, spellIndex);
435
+ if (learned) {
436
+ state.modHooks?.abilityGained?.({
437
+ kind: "spell",
438
+ spellIndex,
439
+ name: learned.name,
440
+ realm: learned.realm.name,
441
+ command: "cast",
442
+ });
443
+ }
434
444
  calcSpells(player, deps.statInd, env.msg);
435
445
  return state.z.moveEnergy;
436
446
  });
package/src/index.ts CHANGED
@@ -129,6 +129,10 @@ export * from "./session/save-migrate.js";
129
129
  * the fold, so both are part of the published API - see mod/hooks.ts. */
130
130
  export * from "./mod/hooks.js";
131
131
  export * from "./mod/save-blocks.js";
132
+ /* The player-facing half of the same store: what is quarantined, which pack
133
+ * owned it, and whether that pack can be found now. Read-only over
134
+ * save-blocks.ts's storage - see mod/orphan-stash.ts. */
135
+ export * from "./mod/orphan-stash.js";
132
136
  export * from "./mod/ids.js";
133
137
  export * from "./mod/registry-host.js";
134
138
  export * from "./mod/vocabulary.js";
package/src/mod/hooks.ts CHANGED
@@ -114,6 +114,24 @@ export interface HistoryDisplayEntry {
114
114
  readonly expandUserInput?: true;
115
115
  }
116
116
 
117
+ /** A newly available player ability, named without exposing a mutable game object. */
118
+ export type AbilityGained =
119
+ | {
120
+ readonly kind: "spell";
121
+ readonly spellIndex: number;
122
+ readonly name: string;
123
+ readonly realm: string;
124
+ /** The host command that opens this spell's casting picker. */
125
+ readonly command: "cast";
126
+ }
127
+ | {
128
+ readonly kind: "activation";
129
+ readonly kindIndex: number;
130
+ readonly name: string;
131
+ /** The host command that opens the activation picker. */
132
+ readonly command: "activate";
133
+ };
134
+
117
135
  /**
118
136
  * A mod's behaviour contributions. Every member is optional; an absent member
119
137
  * means "this mod does not touch that point" and core takes its faithful path.
@@ -387,6 +405,16 @@ export interface ModHooks {
387
405
  * choices past the character they were made on).
388
406
  */
389
407
  optionsChanged?: (options: OptionStateData) => void;
408
+
409
+ /**
410
+ * The player learned a spell or gained a known activatable item
411
+ * (spell-cmd.ts, pickup.ts, and obj-cmd.ts). This is a notification: core has already
412
+ * committed the knowledge and does not read a return value. `command` is the
413
+ * semantic command a host turns into its current keyset's activation input;
414
+ * a mod may use it when offering a keymap without guessing which command the
415
+ * player uses.
416
+ */
417
+ abilityGained?: (ability: AbilityGained) => void;
390
418
  }
391
419
 
392
420
  /**
@@ -415,7 +443,7 @@ export interface ModHooks {
415
443
  * - ANY hooks (saveNoiseScent, shapeLearnObviousFlagsDirectly) are disjunctive:
416
444
  * one mod asking for the data is enough, because the data is additive and a
417
445
  * second mod cannot object.
418
- * - NOTIFICATION hooks (optionsChanged, levelRevisited) call every contributor
446
+ * - NOTIFICATION hooks (optionsChanged, levelRevisited, abilityGained) call every contributor
419
447
  * in load order. There is no answer for a later mod to override.
420
448
  *
421
449
  * WHY THE LAST TWO ARE NOT EXCEPTIONS. "Later wins" answers the question "two
@@ -478,6 +506,7 @@ export const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>> = {
478
506
  levelRevisited: "all-observe",
479
507
  messageText: "chained",
480
508
  optionsChanged: "all-observe",
509
+ abilityGained: "all-observe",
481
510
  };
482
511
 
483
512
  /**
@@ -658,6 +687,13 @@ export function guardModHooks(
658
687
  };
659
688
  }
660
689
 
690
+ const ability = hooks.abilityGained;
691
+ if (ability) {
692
+ out.abilityGained = (gained): void => {
693
+ guard("abilityGained", () => ability({ ...gained }), undefined);
694
+ };
695
+ }
696
+
661
697
  return out;
662
698
  }
663
699
 
@@ -814,6 +850,13 @@ export function composeModHooks(
814
850
  };
815
851
  }
816
852
 
853
+ const abilityGained = list.map((c) => c.abilityGained).filter(isFn);
854
+ if (abilityGained.length > 0) {
855
+ out.abilityGained = (gained): void => {
856
+ for (const fn of abilityGained) fn({ ...gained });
857
+ };
858
+ }
859
+
817
860
  return out;
818
861
  }
819
862
 
@@ -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
+ }
@@ -328,10 +328,15 @@ export type MenuTransformer = (
328
328
  rows: readonly MenuTransformRow[],
329
329
  ) => readonly MenuTransformRow[];
330
330
 
331
+ /** Code to run after the player selects a mod-owned menu action. */
332
+ export type MenuActionHandler = () => void | Promise<void>;
333
+
331
334
  /** Structural target implemented by the web front end, not by headless core. */
332
335
  export interface MenuRegistryTarget {
333
336
  register(id: string, transformer: MenuTransformer, owner?: string): void;
334
337
  handlerFor(id: string): MenuTransformer | null;
338
+ /** Add one owned, runnable row to an existing menu. */
339
+ addAction?(id: string, action: string, label: string, handler: MenuActionHandler, owner?: string): void;
335
340
  }
336
341
 
337
342
  /**
@@ -680,6 +685,16 @@ export interface MenuFacade {
680
685
  register(id: string, transformer: MenuTransformer): void;
681
686
  /** The currently installed transformer, for layering/wrapping an earlier mod. */
682
687
  handlerFor(id: string): MenuTransformer | null;
688
+ /**
689
+ * Add a row the host can actually run, rather than merely transforming the
690
+ * presentation of a row whose action core already knows. `action` is scoped
691
+ * to this mod, so two mods may both call theirs "backup" without colliding.
692
+ *
693
+ * The first supported location is `core:game-menu`, the Escape game menu.
694
+ * The callback starts directly from the selection gesture, so a browser-only
695
+ * action such as `ctx.backupFolder.choose()` may still open its folder picker.
696
+ */
697
+ addAction(id: "core:game-menu", action: string, label: string, handler: MenuActionHandler): void;
683
698
  }
684
699
 
685
700
  /**
@@ -1608,6 +1623,14 @@ export function createModRegistryHost(
1608
1623
  requireCap(capabilities, "menu");
1609
1624
  return requireTarget(targets.menus, "menu").handlerFor(id);
1610
1625
  },
1626
+ addAction(id, action, label, handler): void {
1627
+ requireCap(capabilities, "menu");
1628
+ const menus = requireTarget(targets.menus, "menu");
1629
+ if (!menus.addAction) {
1630
+ throw new Error("mod registry: menu actions are not available in this game (host did not wire them)");
1631
+ }
1632
+ menus.addAction(id, action, label, handler);
1633
+ },
1611
1634
  },
1612
1635
  tiles: {
1613
1636
  register(filler): void {
@@ -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.3.0";
27
+ export const ENGINE_VERSION = "1.9.0";