@rpgm-tools/neo-angband-core 0.28.0 → 0.29.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.
@@ -28,7 +28,7 @@ export const CORE_RECORD_KEYS: Readonly<Record<string, readonly string[]>> = {
28
28
  "curse": ["combat", "conflict", "conflict-flags", "desc", "effect", "flags", "msg", "name", "time", "type", "values"],
29
29
  "dungeon_profile": ["alloc", "min-level", "name", "params", "room", "streamer", "tunnel"],
30
30
  "ego_item": ["alloc", "brand", "combat", "desc", "flags", "flags-off", "info", "item", "min-combat", "min-values", "name", "slay", "type", "values"],
31
- "flavor": ["fixed", "flavor", "kind"],
31
+ "flavor": ["entries", "kind"],
32
32
  "hints": ["H"],
33
33
  "history": ["chart", "phrase"],
34
34
  "monster": ["armor-class", "base", "blow", "color", "color-cycle", "depth", "desc", "drop", "drop-base", "drop-order", "experience", "flags", "flags-off", "friends", "friends-base", "glyph", "hearing", "hit-points", "innate-freq", "light", "message-invis", "message-vis", "mimic", "name", "plural", "rarity", "shape", "sleepiness", "smell", "speed", "spell-freq", "spell-power", "spells"],
@@ -82,10 +82,9 @@ export const CORE_RECORD_KEYS: Readonly<Record<string, readonly string[]>> = {
82
82
  * `ext`, believe the patch worked, and never learn the real field went
83
83
  * untouched. The namespace is also what stops two mods coining the same word
84
84
  * from silently overwriting each other. The rule is enforced at composition
85
- * (mod-sdk fields.ts): the owning mod must DECLARE the field, and another pack
86
- * may write it only when it declares that owner as a dependency. This is the
87
- * same rule at the other end, so a record that reached core by some other route
88
- * cannot bypass it.
85
+ * (mod-sdk fields.ts), which additionally requires the owning mod to have
86
+ * DECLARED the field; this is the same rule at the other end, so a record that
87
+ * reached core by some other route cannot bypass it.
89
88
  */
90
89
  export function extensionData(
91
90
  file: string,
@@ -86,7 +86,19 @@ export interface SavePackRef {
86
86
  id: string;
87
87
  /** Semver version string, e.g. "1.2.0". */
88
88
  version: string;
89
- /** Content hash of the pack that produced the save (optional). */
89
+ /**
90
+ * Content hash of the pack that produced the save (optional).
91
+ *
92
+ * ABSENT MEANS UNMEASURED, NEVER "UNCHANGED" - the same convention
93
+ * `InstalledModMeta.digests` and `SessionMod.digest` already use on the web
94
+ * side. Compared by `mismatchedNamespaces` (below) against the SAME
95
+ * namespace's current hash, when a caller has one: this is what lets a load
96
+ * tell "this pack patched a record instead of only adding one, and the
97
+ * patch is now different or gone" apart from an ordinary reload, which
98
+ * `orphanedNamespaces` alone cannot (issue #20) - a PATCHED core record
99
+ * still resolves under core's own, still-present namespace, so no entity is
100
+ * ever orphaned by the patch going away.
101
+ */
90
102
  hash?: string;
91
103
  /** Source (git URL + ref, or marketplace id) the pack was installed from. */
92
104
  source?: string;
@@ -269,6 +281,71 @@ export function orphanedNamespaces(
269
281
  return out;
270
282
  }
271
283
 
284
+ /**
285
+ * Compare the pack set that produced a save against the packs present NOW,
286
+ * for the namespaces on BOTH sides - the sibling case `orphanedNamespaces`
287
+ * cannot see (issue #20). A pack that ADDS content and later goes away leaves
288
+ * an orphan behind (its own ids no longer resolve, so quarantine catches it);
289
+ * a pack that only PATCHES an existing record - a session mod re-pricing a
290
+ * core sword's damage, say - leaves nothing behind to orphan, because the
291
+ * record it touched still resolves under its own, still-present namespace.
292
+ * The composed value from save time is simply gone, silently, on the next
293
+ * load. This is that load's chance to notice: it returns the namespaces whose
294
+ * recorded hash and current hash are both known and disagree.
295
+ *
296
+ * "core" is never reported: core's own content changing between builds is an
297
+ * engine-version question (ENGINE_VERSION / SAVE_VERSION), not a mod-patch one.
298
+ *
299
+ * A namespace missing a hash on EITHER side is never reported - not measured
300
+ * is not the same claim as changed, and a false "this mod changed" alarm off
301
+ * a gap in the data would be worse than the silent gap this function exists
302
+ * to close. `currentPacks` need not cover every present namespace: a caller
303
+ * that cannot measure a pack's current content simply omits it, and that
304
+ * pack's own comparison is skipped rather than guessed at.
305
+ */
306
+ export function mismatchedNamespaces(
307
+ manifest: SaveManifest,
308
+ currentPacks: readonly SavePackRef[],
309
+ ): string[] {
310
+ const current = new Map(currentPacks.map((p) => [p.id, p]));
311
+ const out: string[] = [];
312
+ for (const recorded of manifest.packs) {
313
+ if (recorded.id === "core") continue;
314
+ if (!recorded.hash) continue;
315
+ const now = current.get(recorded.id);
316
+ if (!now?.hash) continue;
317
+ if (now.hash !== recorded.hash) out.push(recorded.id);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /**
323
+ * Fold the packs present now into a manifest's pack list, for the NEXT save to
324
+ * carry forward: a namespace `currentPacks` supplies replaces whatever was
325
+ * recorded for it (so a changed hash/version is what the next
326
+ * `mismatchedNamespaces`/`orphanedNamespaces` call compares against); a
327
+ * namespace it does NOT supply keeps its old recorded entry untouched, rather
328
+ * than being dropped, so a pack this host cannot currently measure - or one
329
+ * that has gone away entirely - is still there for `orphanedNamespaces` to
330
+ * find missing later. Pure: neither argument is mutated.
331
+ */
332
+ export function reconcilePackManifest(
333
+ manifest: SaveManifest,
334
+ currentPacks: readonly SavePackRef[],
335
+ ): SaveManifest {
336
+ const current = new Map(currentPacks.map((p) => [p.id, p]));
337
+ const seen = new Set<string>();
338
+ const packs: SavePackRef[] = [];
339
+ for (const recorded of manifest.packs) {
340
+ packs.push(current.get(recorded.id) ?? recorded);
341
+ seen.add(recorded.id);
342
+ }
343
+ for (const pack of currentPacks) {
344
+ if (!seen.has(pack.id)) packs.push(pack);
345
+ }
346
+ return { ...manifest, packs };
347
+ }
348
+
272
349
  /** The version each pack was at when the save was written (for orphan keys). */
273
350
  function versionMap(manifest: SaveManifest): Map<string, string> {
274
351
  const m = new Map<string, string>();
package/src/obj/bind.ts CHANGED
@@ -1486,12 +1486,35 @@ export class ObjRegistry {
1486
1486
  for (const rec of records) {
1487
1487
  const tval = tvalFindIdx(rec.kind.tval);
1488
1488
  if (tval <= 0) throw new Error(`flavor: unknown tval ${rec.kind.tval}`);
1489
- const bindEntry = (entry: {
1490
- index: number;
1491
- sval?: string;
1492
- attr: string;
1493
- desc?: string;
1494
- }): void => {
1489
+ /*
1490
+ * FILE ORDER, DIRECTLY. flavor.txt writes a record's `fixed:` lines
1491
+ * above its `flavor:` lines (rings L18-30 before L32-73, amulets
1492
+ * L90-101 before L104-117), and upstream's parser builds one list in
1493
+ * the order the lines arrive - so this array has to be in that order
1494
+ * too. It is not cosmetic: `flavorInit` walks this list BACKWARDS to
1495
+ * reproduce C's prepend-into-a-linked-list (obj/flavor.ts,
1496
+ * flavor_assign_random), so a flavour's position in it is what decides
1497
+ * which kind it lands on.
1498
+ *
1499
+ * A binder that bound every `flavor:` line before every `fixed:` line
1500
+ * was invisible in an ordinary game, because a fixed flavour keeps its
1501
+ * own sval and `flavor_assign_random` skips it - the random ones kept
1502
+ * their relative order and every potion looked right. Under
1503
+ * `birth_randarts` it was not: `flavor_reset_fixed` scrubs every fixed
1504
+ * sval but the One Ring's, which throws all eight ring and amulet
1505
+ * `fixed:` entries into the random pool, and they landed at the wrong
1506
+ * end of the list. Same number of RNG draws either way, so the stream
1507
+ * never moved and nothing downstream diverged - it just handed out the
1508
+ * wrong gems.
1509
+ *
1510
+ * `entries` (issue #2) is what makes this correct for ANY line order,
1511
+ * not just the shipped fixed-then-flavor layout: the content compiler
1512
+ * emits one list per record, already in file order, with `kind` naming
1513
+ * which directive produced each entry - so a mod's flavor.txt that
1514
+ * interleaves `fixed:` and `flavor:` binds in its own true order
1515
+ * instead of always fixed-first.
1516
+ */
1517
+ for (const entry of rec.entries) {
1495
1518
  let sval = SV_UNKNOWN;
1496
1519
  if (entry.sval !== undefined) {
1497
1520
  sval = this.lookupSval(tval, entry.sval);
@@ -1504,34 +1527,7 @@ export class ObjRegistry {
1504
1527
  dChar: rec.kind.glyph,
1505
1528
  text: entry.desc ?? "",
1506
1529
  });
1507
- };
1508
- /*
1509
- * FIXED FIRST, WHICH IS FILE ORDER. flavor.txt writes a record's
1510
- * `fixed:` lines above its `flavor:` lines (rings L18-30 before L32-73,
1511
- * amulets L90-101 before L104-117), and the parser builds one list in
1512
- * the order the lines arrive - so this array has to be in that order too.
1513
- * It is not cosmetic: `flavorInit` walks this list BACKWARDS to reproduce
1514
- * C's prepend-into-a-linked-list (obj/flavor.ts, flavor_assign_random),
1515
- * so a flavour's position in it is what decides which kind it lands on.
1516
- *
1517
- * Binding `flavor:` first was invisible in an ordinary game, because a
1518
- * fixed flavour keeps its own sval and `flavor_assign_random` skips it -
1519
- * the random ones kept their relative order and every potion looked
1520
- * right. Under `birth_randarts` it was not: `flavor_reset_fixed` scrubs
1521
- * every fixed sval but the One Ring's, which throws all eight ring and
1522
- * amulet `fixed:` entries into the random pool, and they were at the
1523
- * wrong end of the list. Same number of RNG draws either way, so the
1524
- * stream never moved and nothing downstream diverged - it just handed
1525
- * out the wrong gems.
1526
- *
1527
- * The compiled record splits the two into separate arrays, so a record
1528
- * that INTERLEAVED them could not be reproduced from this shape at all.
1529
- * Nothing shipped does, and recovering it would mean the compiler
1530
- * emitting one ordered list; the entry `index` cannot stand in for it,
1531
- * since flavor.txt's own numbering is not the order it writes them in.
1532
- */
1533
- for (const entry of rec.fixed ?? []) bindEntry(entry);
1534
- for (const entry of rec.flavor ?? []) bindEntry(entry);
1530
+ }
1535
1531
  }
1536
1532
  }
1537
1533
 
package/src/obj/types.ts CHANGED
@@ -318,7 +318,14 @@ export interface ObjectPropertyRecordJson {
318
318
  bindui?: unknown;
319
319
  }
320
320
 
321
+ /**
322
+ * One `flavor:` or `fixed:` line. `kind` tags which directive it came from -
323
+ * only a `fixed:` line carries `sval` - and its position in the record's
324
+ * `entries` array is the file's true line order (issue #2): flavor.txt's own
325
+ * `index` numbering is not that order, so it cannot stand in for it.
326
+ */
321
327
  export interface FlavorEntryJson {
328
+ kind: "flavor" | "fixed";
322
329
  index: number;
323
330
  sval?: string;
324
331
  attr: string;
@@ -327,8 +334,8 @@ export interface FlavorEntryJson {
327
334
 
328
335
  export interface FlavorRecordJson {
329
336
  kind: { tval: string; glyph: string };
330
- flavor?: FlavorEntryJson[];
331
- fixed?: FlavorEntryJson[];
337
+ /** `flavor:` and `fixed:` lines, ONE list, in the order flavor.txt writes them. */
338
+ entries: FlavorEntryJson[];
332
339
  }
333
340
 
334
341
  /** The full bundle of pack JSON record arrays the registry binds from. */
@@ -337,10 +337,17 @@ import { migrateSave } from "./save-migrate.js";
337
337
  import { ContentIdResolver } from "../mod/ids.js";
338
338
  import {
339
339
  coreOnlyManifest,
340
+ mismatchedNamespaces,
340
341
  quarantineSave,
342
+ reconcilePackManifest,
341
343
  rehydrateSave,
342
344
  } from "../mod/save-blocks.js";
343
- import type { ModBag, OrphanStore, SaveManifest } from "../mod/save-blocks.js";
345
+ import type {
346
+ ModBag,
347
+ OrphanStore,
348
+ SaveManifest,
349
+ SavePackRef,
350
+ } from "../mod/save-blocks.js";
344
351
 
345
352
  /**
346
353
  * The getItem seam body (cmd_get_item's "tgtitem" fast path, cmd-core.c L1060):
@@ -518,6 +525,17 @@ export interface StartedGame {
518
525
  orphans: OrphanStore;
519
526
  /** decision-8: whether the one-time orphan keep/purge prompt has been shown. */
520
527
  orphansAcknowledged: boolean;
528
+ /**
529
+ * Namespaces present now whose recorded content hash no longer matches
530
+ * their current one (issue #20, save-blocks.ts mismatchedNamespaces): a
531
+ * pack that PATCHED a record - a session mod re-pricing a core sword,
532
+ * say - instead of only adding one, so nothing was orphaned when the patch
533
+ * changed or the pack went away. Empty on a core-only game, on a save
534
+ * written before this field, and whenever the loading host supplied no
535
+ * `currentPacks` (or none with a measurable hash) to compare against -
536
+ * absent evidence is not evidence of a match.
537
+ */
538
+ mismatchedPacks: readonly string[];
521
539
  /**
522
540
  * What loadGame had to convert to read this save (session/save-migrate.ts).
523
541
  * Absent on a new game and on a save already at SAVE_VERSION. `applied` is one
@@ -3792,6 +3810,7 @@ export function startGame(pack: GamePack, opts: StartGameOptions = {}): StartedG
3792
3810
  mods: {},
3793
3811
  orphans: {},
3794
3812
  orphansAcknowledged: false,
3813
+ mismatchedPacks: [],
3795
3814
  options,
3796
3815
  randartSeed,
3797
3816
  changeLevel,
@@ -4211,6 +4230,19 @@ export interface LoadGameOptions {
4211
4230
  * entry (WP-14) passes this; a normal load leaves it false.
4212
4231
  */
4213
4232
  wizard?: boolean;
4233
+ /**
4234
+ * The content digest this host can measure RIGHT NOW for each present pack
4235
+ * (issue #20) - one `SavePackRef` per namespace it can hash, with the pack's
4236
+ * CURRENT id/version/hash, not the save's recorded one. Compared against
4237
+ * the save's own recorded manifest (`mismatchedNamespaces`) to catch a
4238
+ * pack that PATCHED a record rather than only adding one, then folded into
4239
+ * the manifest this load returns (`reconcilePackManifest`) so the NEXT save
4240
+ * carries today's hash forward. Omitting an entry for a present namespace
4241
+ * is the honest "cannot measure this one yet" - it is compared against
4242
+ * nothing and never flagged as a mismatch. Absent/empty: no pack is
4243
+ * compared or updated, which is the behavior before this option existed.
4244
+ */
4245
+ currentPacks?: readonly SavePackRef[];
4214
4246
  }
4215
4247
 
4216
4248
  export function loadGame(
@@ -4276,9 +4308,23 @@ export function loadGame(
4276
4308
  modNoscore: saveIn.manifest?.modNoscore ?? false,
4277
4309
  };
4278
4310
  const isPresent = (ns: string): boolean => present.has(ns);
4311
+
4312
+ /* Issue #20's sibling check: a pack that PATCHED a record (rather than only
4313
+ * adding one) leaves nothing for quarantine to catch when the patch changes
4314
+ * or the pack goes away, because the record still resolves under its own,
4315
+ * still-present namespace. Computed against the manifest AS RECORDED, before
4316
+ * folding in what the host can measure now, so a genuinely different hash is
4317
+ * what gets reported rather than something this call just overwrote.
4318
+ * `reconcilePackManifest` then carries today's hash forward for the NEXT
4319
+ * save - a namespace the host cannot measure keeps its old recorded entry,
4320
+ * exactly as if this option had not been passed. */
4321
+ const currentPacks = opts.currentPacks ?? [];
4322
+ const mismatchedPacks = mismatchedNamespaces(manifest, currentPacks);
4323
+ const reconciledManifest = reconcilePackManifest(manifest, currentPacks);
4324
+
4279
4325
  const quarantine = quarantineSave(
4280
4326
  rehydrateSave(saveIn, isPresent),
4281
- manifest,
4327
+ reconciledManifest,
4282
4328
  isPresent,
4283
4329
  );
4284
4330
  const save = quarantine.save;
@@ -4680,10 +4726,11 @@ export function loadGame(
4680
4726
  flavor: wired.flavor,
4681
4727
  everseen: wired.everseen,
4682
4728
  seedFlavor,
4683
- manifest,
4729
+ manifest: reconciledManifest,
4684
4730
  mods: save.mods ?? {},
4685
4731
  orphans: quarantine.orphans,
4686
4732
  orphansAcknowledged: save.orphansAcknowledged ?? false,
4733
+ mismatchedPacks,
4687
4734
  ...(migration.applied.length > 0 || migration.notes.length > 0
4688
4735
  ? { saveMigration: { applied: migration.applied, notes: migration.notes } }
4689
4736
  : {}),
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.28.0";
26
+ export const ENGINE_VERSION = "0.29.0";