@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.
- package/README.md +7 -7
- package/dist/game/obj-cmd.d.ts.map +1 -1
- package/dist/game/obj-cmd.js +16 -3
- package/dist/game/obj-cmd.js.map +1 -1
- package/dist/game/pickup.d.ts.map +1 -1
- package/dist/game/pickup.js +17 -1
- package/dist/game/pickup.js.map +1 -1
- package/dist/game/player-turn.d.ts +2 -0
- package/dist/game/player-turn.d.ts.map +1 -1
- package/dist/game/player-turn.js +4 -0
- package/dist/game/player-turn.js.map +1 -1
- package/dist/game/spell-cmd.d.ts.map +1 -1
- package/dist/game/spell-cmd.js +10 -0
- package/dist/game/spell-cmd.js.map +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/hooks.d.ts +25 -1
- package/dist/mod/hooks.d.ts.map +1 -1
- package/dist/mod/hooks.js +14 -0
- package/dist/mod/hooks.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/registry-host.d.ts +14 -0
- package/dist/mod/registry-host.d.ts.map +1 -1
- package/dist/mod/registry-host.js +8 -0
- package/dist/mod/registry-host.js.map +1 -1
- 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/game/obj-cmd.ts +17 -3
- package/src/game/pickup.ts +20 -0
- package/src/game/player-turn.ts +5 -0
- package/src/game/spell-cmd.ts +10 -0
- package/src/index.ts +4 -0
- package/src/mod/hooks.ts +44 -1
- package/src/mod/orphan-stash.ts +285 -0
- package/src/mod/registry-host.ts +23 -0
- package/src/mod/save-blocks.ts +8 -3
- package/src/session/game.ts +16 -0
- package/src/version.ts +1 -1
package/src/game/player-turn.ts
CHANGED
|
@@ -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
|
}
|
package/src/game/spell-cmd.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/mod/registry-host.ts
CHANGED
|
@@ -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 {
|
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