@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.
- package/README.md +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mod/orphan-stash.d.ts +158 -0
- package/dist/mod/orphan-stash.d.ts.map +1 -0
- package/dist/mod/orphan-stash.js +172 -0
- package/dist/mod/orphan-stash.js.map +1 -0
- package/dist/mod/save-blocks.d.ts +9 -4
- package/dist/mod/save-blocks.d.ts.map +1 -1
- package/dist/mod/save-blocks.js +8 -3
- package/dist/mod/save-blocks.js.map +1 -1
- package/dist/session/game.d.ts +14 -0
- package/dist/session/game.d.ts.map +1 -1
- package/dist/session/game.js +2 -0
- package/dist/session/game.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/mod/orphan-stash.ts +285 -0
- package/src/mod/save-blocks.ts +8 -3
- package/src/session/game.ts +16 -0
- package/src/version.ts +1 -1
|
@@ -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
|
+
}
|
package/src/mod/save-blocks.ts
CHANGED
|
@@ -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.
|
|
56
|
-
* (
|
|
57
|
-
*
|
|
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";
|
package/src/session/game.ts
CHANGED
|
@@ -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