@rpgm-tools/neo-angband-core 0.34.2 → 1.0.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 (62) hide show
  1. package/README.md +6 -4
  2. package/dist/agent/entity-views.d.ts.map +1 -1
  3. package/dist/agent/entity-views.js +14 -0
  4. package/dist/agent/entity-views.js.map +1 -1
  5. package/dist/agent/types.d.ts +39 -1
  6. package/dist/agent/types.d.ts.map +1 -1
  7. package/dist/agent/types.js +11 -1
  8. package/dist/agent/types.js.map +1 -1
  9. package/dist/game/gear.d.ts +6 -0
  10. package/dist/game/gear.d.ts.map +1 -1
  11. package/dist/game/gear.js +15 -3
  12. package/dist/game/gear.js.map +1 -1
  13. package/dist/game/obj-cmd.d.ts +19 -0
  14. package/dist/game/obj-cmd.d.ts.map +1 -1
  15. package/dist/game/obj-cmd.js +32 -2
  16. package/dist/game/obj-cmd.js.map +1 -1
  17. package/dist/game/wizard.d.ts +1 -1
  18. package/dist/game/wizard.js +1 -1
  19. package/dist/mod/hooks.d.ts +57 -6
  20. package/dist/mod/hooks.d.ts.map +1 -1
  21. package/dist/mod/hooks.js +39 -0
  22. package/dist/mod/hooks.js.map +1 -1
  23. package/dist/mod/save-blocks.d.ts +1 -1
  24. package/dist/mod/save-blocks.d.ts.map +1 -1
  25. package/dist/mon/bind.d.ts +17 -0
  26. package/dist/mon/bind.d.ts.map +1 -1
  27. package/dist/mon/bind.js +115 -8
  28. package/dist/mon/bind.js.map +1 -1
  29. package/dist/obj/bind.d.ts.map +1 -1
  30. package/dist/obj/bind.js +63 -10
  31. package/dist/obj/bind.js.map +1 -1
  32. package/dist/rng.d.ts +1 -1
  33. package/dist/rng.js +1 -1
  34. package/dist/session/game.d.ts.map +1 -1
  35. package/dist/session/game.js +8 -0
  36. package/dist/session/game.js.map +1 -1
  37. package/dist/store/bind.d.ts +1 -1
  38. package/dist/store/bind.d.ts.map +1 -1
  39. package/dist/store/bind.js +39 -5
  40. package/dist/store/bind.js.map +1 -1
  41. package/dist/version.d.ts +9 -8
  42. package/dist/version.d.ts.map +1 -1
  43. package/dist/version.js +9 -8
  44. package/dist/version.js.map +1 -1
  45. package/dist/world/feature.d.ts +6 -0
  46. package/dist/world/feature.d.ts.map +1 -1
  47. package/dist/world/feature.js +43 -7
  48. package/dist/world/feature.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/agent/entity-views.ts +14 -0
  51. package/src/agent/types.ts +39 -1
  52. package/src/game/gear.ts +22 -3
  53. package/src/game/obj-cmd.ts +47 -2
  54. package/src/game/wizard.ts +1 -1
  55. package/src/mod/hooks.ts +105 -6
  56. package/src/mon/bind.ts +129 -10
  57. package/src/obj/bind.ts +63 -9
  58. package/src/rng.ts +1 -1
  59. package/src/session/game.ts +8 -0
  60. package/src/store/bind.ts +39 -7
  61. package/src/version.ts +9 -8
  62. package/src/world/feature.ts +44 -6
package/src/mod/hooks.ts CHANGED
@@ -71,6 +71,7 @@
71
71
  */
72
72
 
73
73
  import type { GameState } from "../game/context.js";
74
+ import type { GameObject } from "../obj/object.js";
74
75
  import type { OptionStateData } from "../player/options.js";
75
76
  import type { Chunk } from "../world/chunk.js";
76
77
 
@@ -214,6 +215,57 @@ export interface ModHooks {
214
215
  */
215
216
  artifactCommit?: (aidx: number, alreadyCreated: boolean) => boolean;
216
217
 
218
+ /**
219
+ * A partial merge that would combine two uneven stacks, when the SOURCE
220
+ * stack `drained` is about to be shrunk to top up `receiving` (game/gear.ts,
221
+ * combinePack's call to objectAbsorbPartial - only the non-quiver branch,
222
+ * where the residual bug this seam exists for actually lives).
223
+ *
224
+ * Return false to refuse the merge; the two stacks are left exactly as they
225
+ * were, and combinePack moves on to the next candidate exactly as it would
226
+ * if invenCanStackPartial itself had refused. Faithful core performs the
227
+ * merge unconditionally - upstream's own inven_can_stack_partial has no such
228
+ * check, and neither does the port's line-for-line copy.
229
+ *
230
+ * READ-ONLY. Nothing about this seam invites a hook to edit `drained` or
231
+ * `receiving`; it decides only whether the merge proceeds, the same way
232
+ * artifactCommit decides only whether the commit proceeds.
233
+ *
234
+ * RNG-FREE: no rng is passed and none may be reached, matching every other
235
+ * seam inside the object pipeline.
236
+ *
237
+ * Serves: the bug-fixes mod's stack-charge-drift guard (#6355 residual,
238
+ * neostryder/neo-angband#115).
239
+ */
240
+ partialStackMerge?: (drained: GameObject, receiving: GameObject) => boolean;
241
+
242
+ /**
243
+ * Which handle pack_overflow should shed when its caller passed no explicit
244
+ * victim (game/obj-cmd.ts, packOverflow's `handle === 0` / upstream NULL
245
+ * branch - the safety net session/game.ts's overflowPack runs before every
246
+ * command, game-world.c:941-947).
247
+ *
248
+ * `departedQuiver` is the one handle GameState.gear.quiver held immediately
249
+ * before the recompute that just ran and no longer holds now - core's own
250
+ * computed fact, the same way historyAdd hands over `duplicate`. A note-only
251
+ * change (an inscription, say) can drop an item's preferred_quiver_slot match
252
+ * and displace it into the ordinary pack, turning a slot-weighted quiver
253
+ * stack into a full pack slot of its own and tipping an otherwise-steady pack
254
+ * into overflow; `departedQuiver` names that item. Null when nothing left the
255
+ * quiver on this recompute (an ordinary overflow from picking something up,
256
+ * say), in which case there is nothing to redirect toward.
257
+ *
258
+ * Return the handle to shed, or null to decline. Declining MUST be free of
259
+ * observable effect - core then sheds `state.gear.inven[length-1]` exactly as
260
+ * it would with no mod loaded, so asking a mod that declines costs nothing.
261
+ *
262
+ * RNG-FREE: no rng is passed and none may be reached.
263
+ *
264
+ * Serves: the bug-fixes mod's pack-overflow-victim correction
265
+ * (neostryder/neo-angband#116).
266
+ */
267
+ packOverflowVictim?: (state: GameState, departedQuiver: number | null) => number | null;
268
+
217
269
  /**
218
270
  * A character-history entry about to be written (session/game.ts and the
219
271
  * host's note command).
@@ -346,10 +398,11 @@ export interface ModHooks {
346
398
  * one obeys it too - see the note below for the two folds that look like
347
399
  * exceptions and are not.
348
400
  *
349
- * - LAST-HANDLER hooks (walkBlockedByDiggable) are asked in REVERSE load order
350
- * and stop at the first non-null, so the last mod to have an opinion is the
351
- * one whose handling takes effect and no earlier mod can double-spend the
352
- * energy it already paid out.
401
+ * - LAST-HANDLER hooks (walkBlockedByDiggable, packOverflowVictim) are asked
402
+ * in REVERSE load order and stop at the first non-null, so the last mod to
403
+ * have an opinion is the one whose handling takes effect and no earlier mod
404
+ * can double-spend the energy it already paid out (or, for
405
+ * packOverflowVictim, second-guess a redirect an earlier mod already chose).
353
406
  * - ORDERING hooks (objectListTiebreak) chain the same way round: the last
354
407
  * mod's comparator is the primary key and earlier ones break the ties it
355
408
  * leaves, which is a valid total order and is "later wins" for a comparator.
@@ -357,8 +410,8 @@ export interface ModHooks {
357
410
  * load order, each seeing the previous one's output - so the last mod still
358
411
  * speaks last and has the final say over the text that reaches the player,
359
412
  * or over the radius the blast is built from.
360
- * - VETO hooks (levelGenerated, artifactCommit, historyAdd) are conjunctive:
361
- * every contributor runs and any refusal decides.
413
+ * - VETO hooks (levelGenerated, artifactCommit, historyAdd, partialStackMerge)
414
+ * are conjunctive: every contributor runs and any refusal decides.
362
415
  * - ANY hooks (saveNoiseScent, shapeLearnObviousFlagsDirectly) are disjunctive:
363
416
  * one mod asking for the data is enough, because the data is additive and a
364
417
  * second mod cannot object.
@@ -416,6 +469,8 @@ export const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>> = {
416
469
  projectionRadius: "chained",
417
470
  levelGenerated: "all-must-agree",
418
471
  artifactCommit: "all-must-agree",
472
+ partialStackMerge: "all-must-agree",
473
+ packOverflowVictim: "last-answer",
419
474
  historyAdd: "all-must-agree",
420
475
  historyDisplay: "chained",
421
476
  saveNoiseScent: "any-yes",
@@ -529,6 +584,26 @@ export function guardModHooks(
529
584
  guard("artifactCommit", () => artifact(aidx, alreadyCreated), true);
530
585
  }
531
586
 
587
+ const partialMerge = hooks.partialStackMerge;
588
+ if (partialMerge) {
589
+ /* PROCEED with the merge, which is what core does unconditionally without
590
+ * the hook. */
591
+ out.partialStackMerge = (drained, receiving): boolean =>
592
+ guard("partialStackMerge", () => partialMerge(drained, receiving), true);
593
+ }
594
+
595
+ const overflowVictim = hooks.packOverflowVictim;
596
+ if (overflowVictim) {
597
+ /* null is DECLINE, so core sheds the trailing inven[] entry as it would
598
+ * with no mod loaded. */
599
+ out.packOverflowVictim = (state, departedQuiver): number | null =>
600
+ guard(
601
+ "packOverflowVictim",
602
+ () => overflowVictim(state, departedQuiver),
603
+ null,
604
+ );
605
+ }
606
+
532
607
  const history = hooks.historyAdd;
533
608
  if (history) {
534
609
  /* WRITE the entry: faithful core writes every entry it reaches. Suppressing
@@ -656,6 +731,30 @@ export function composeModHooks(
656
731
  };
657
732
  }
658
733
 
734
+ const partialMerge = list.map((c) => c.partialStackMerge).filter(isFn);
735
+ if (partialMerge.length > 0) {
736
+ out.partialStackMerge = (drained, receiving): boolean => {
737
+ for (const fn of partialMerge) if (!fn(drained, receiving)) return false;
738
+ return true;
739
+ };
740
+ }
741
+
742
+ /* REVERSE load order, like walkBlockedByDiggable: the mod moved to the
743
+ * bottom of the list is asked first, so it is the one whose redirect wins. */
744
+ const overflowVictim = list
745
+ .map((c) => c.packOverflowVictim)
746
+ .filter(isFn)
747
+ .reverse();
748
+ if (overflowVictim.length > 0) {
749
+ out.packOverflowVictim = (state, departedQuiver): number | null => {
750
+ for (const fn of overflowVictim) {
751
+ const handle = fn(state, departedQuiver);
752
+ if (handle !== null) return handle;
753
+ }
754
+ return null;
755
+ };
756
+ }
757
+
659
758
  const history = list.map((c) => c.historyAdd).filter(isFn);
660
759
  if (history.length > 0) {
661
760
  out.historyAdd = (entry): boolean => {
package/src/mon/bind.ts CHANGED
@@ -657,36 +657,94 @@ export class MonsterRegistry {
657
657
 
658
658
  this.races = [];
659
659
  this.racesByName = new Map();
660
+ /* Parallel to `this.races` - the raw record each bound race came from, kept
661
+ * for the second pass below, which needs `$from` and the ORIGINAL
662
+ * (unreversed) friends/shape lines to attribute a bad name to the right
663
+ * pack. Stays in lockstep with `this.races` because both are only pushed
664
+ * to on the same non-rejected path. */
665
+ const raceRecs: MonsterRecordJson[] = [];
660
666
  for (const rec of pack.monsters) {
661
667
  if (this.rejectDuplicateName(rec)) continue;
668
+ if (this.rejectMissingBase(rec)) continue;
662
669
  const race = this.bindRace(rec, maxSight, innate, breathOrInnate);
663
670
  /* Keys core does not read ride along instead of being dropped - the same
664
671
  * seam as ObjectKind.ext, for the same reason. */
665
672
  this.races.push(attachExt("monster", rec, race));
666
673
  this.racesByName.set(race.name.toLowerCase(), race);
674
+ raceRecs.push(rec);
667
675
  }
668
676
 
669
- /* finish_parse_monster: resolve friends and shape names to races. */
670
- for (const race of this.races) {
671
- for (const f of race.friends) {
677
+ /*
678
+ * finish_parse_monster: resolve friends and shape names to races.
679
+ *
680
+ * `friends:` and `shape:` NAME OTHER RACES, from monster.json - the same
681
+ * mod-appendable list `base` (below) and monster_base.json's own entries
682
+ * come from - so a friend or shape a mod names can go missing exactly the
683
+ * way an ego's `item:` line does: mod A gives a monster a friend mod B
684
+ * defines, the player disables mod B. It used to throw `mon: could not
685
+ * find friend/shape named ...` out of `bindCore` for the whole game.
686
+ *
687
+ * ONE ENTRY LOST IS THE RIGHT SIZE, unlike `base` below: a race with one
688
+ * fewer scripted friend or shape still functions, the same reasoning the
689
+ * ego binder's `poss_items` uses. Iterated backwards so `splice` during
690
+ * the walk cannot skip the entry after the one just removed.
691
+ */
692
+ this.races.forEach((race, i) => {
693
+ const rec = raceRecs[i] as MonsterRecordJson;
694
+ const from = provenanceOf(rec);
695
+ const rawFriends = [...(rec.friends ?? [])].reverse();
696
+ for (let fi = race.friends.length - 1; fi >= 0; fi--) {
697
+ const f = race.friends[fi] as MonsterFriends;
672
698
  f.race =
673
699
  f.name.toLowerCase() === "same" ? race : this.raceByName(f.name);
674
- if (!f.race) {
700
+ if (f.race) continue;
701
+ const owner = fieldOwner(from, "friends", rawFriends[fi]);
702
+ if (owner === null || from === undefined) {
675
703
  throw new Error(
676
704
  `mon: could not find friend named '${f.name}' for '${race.name}'`,
677
705
  );
678
706
  }
707
+ this.refused.push({
708
+ file: "monster",
709
+ record: race.name,
710
+ field: "friends",
711
+ id: owner,
712
+ why: refusalWhy(
713
+ race.name,
714
+ "friend dropped",
715
+ `could not find friend named '${f.name}'`,
716
+ from,
717
+ ),
718
+ });
719
+ race.friends.splice(fi, 1);
679
720
  }
680
- for (const s of race.shapes) {
721
+ const rawShapes = [...(rec.shape ?? [])].reverse();
722
+ for (let si = race.shapes.length - 1; si >= 0; si--) {
723
+ const s = race.shapes[si] as MonsterShape;
681
724
  if (s.base) continue;
682
725
  s.race = this.raceByName(s.name);
683
- if (!s.race) {
726
+ if (s.race) continue;
727
+ const owner = fieldOwner(from, "shape", rawShapes[si]);
728
+ if (owner === null || from === undefined) {
684
729
  throw new Error(
685
730
  `mon: could not find shape named '${s.name}' for '${race.name}'`,
686
731
  );
687
732
  }
733
+ this.refused.push({
734
+ file: "monster",
735
+ record: race.name,
736
+ field: "shape",
737
+ id: owner,
738
+ why: refusalWhy(
739
+ race.name,
740
+ "shape dropped",
741
+ `could not find shape named '${s.name}'`,
742
+ from,
743
+ ),
744
+ });
745
+ race.shapes.splice(si, 1);
688
746
  }
689
- }
747
+ });
690
748
 
691
749
  this.summons = pack.summons.map((rec) => ({
692
750
  name: rec.name,
@@ -739,6 +797,39 @@ export class MonsterRegistry {
739
797
  return true;
740
798
  }
741
799
 
800
+ /**
801
+ * `base:` IS THE RACE, the same way an artifact's `base-object:` is the
802
+ * artifact: every field a race binds - the default glyph, the inherited
803
+ * flags - starts from the template it names, so a race whose base does not
804
+ * resolve is not a race at all. `base` names a MonsterBase, from
805
+ * monster_base.json, and that list is exactly as mod-appendable as
806
+ * monster.json itself - mod A gives a race a base mod B defines, the
807
+ * player disables mod B, and this used to throw `mon: race ... invalid
808
+ * base` out of `bindCore` for the whole game.
809
+ *
810
+ * DROPPING THE WHOLE RACE, not one field of it, is the artifact binder's
811
+ * answer applied here: `ridx` is assigned by push order same as `aidx`, and
812
+ * core's pack composes before any mod's, so a drop can only shift a mod's
813
+ * own races relative to each other - the same outcome as that mod being
814
+ * off.
815
+ */
816
+ private rejectMissingBase(rec: MonsterRecordJson): boolean {
817
+ if (this.bases.has(rec.base)) return false;
818
+ const from = provenanceOf(rec);
819
+ const owner = fieldOwner(from, "base", rec.base);
820
+ if (owner === null || from === undefined) {
821
+ throw new Error(`mon: race ${rec.name}: invalid base ${rec.base}`);
822
+ }
823
+ this.refused.push({
824
+ file: "monster",
825
+ record: rec.name,
826
+ field: "base",
827
+ id: owner,
828
+ why: refusalWhy(rec.name, "race dropped", `unknown base ${rec.base}`, from),
829
+ });
830
+ return true;
831
+ }
832
+
742
833
  /**
743
834
  * lookup_monster (mon-util.c): exact case-insensitive match first,
744
835
  * else the first race (lowest ridx) whose name contains the query as
@@ -764,6 +855,11 @@ export class MonsterRegistry {
764
855
  innate: FlagSet,
765
856
  breathOrInnate: FlagSet,
766
857
  ): MonsterRace {
858
+ /* SAFETY NET, not a live path: the constructor's `rejectMissingBase` has
859
+ * already refused or thrown on a race whose base does not resolve, so
860
+ * `bindRace` is only ever called with one that does. Kept and documented
861
+ * rather than cast away, so a future caller added without going through
862
+ * that guard fails loudly instead of reading `base` as `undefined`. */
767
863
  const base = this.bases.get(rec.base);
768
864
  if (!base) {
769
865
  throw new Error(`mon: race ${rec.name}: invalid base ${rec.base}`);
@@ -858,13 +954,36 @@ export class MonsterRegistry {
858
954
  });
859
955
  }
860
956
 
957
+ /* `friends-base:` names a MonsterBase - the same mod-appendable list
958
+ * `base:` above resolves against - so a mod's friends-base line can go
959
+ * missing exactly the way `base:` can, except one lost entry still
960
+ * leaves a working race (unlike `base:`, which the whole race needs).
961
+ * The store binder's pattern applies at this granularity: the entry is
962
+ * the unit of provenance and of loss. */
963
+ const from = provenanceOf(rec);
861
964
  const friendsBase: MonsterFriendsBase[] = [];
862
965
  for (const f of [...(rec["friends-base"] ?? [])].reverse()) {
863
966
  const fb = this.bases.get(f.name);
864
967
  if (!fb) {
865
- throw new Error(
866
- `mon: race ${rec.name}: invalid friends base ${f.name}`,
867
- );
968
+ const owner = fieldOwner(from, "friends-base", f);
969
+ if (owner === null || from === undefined) {
970
+ throw new Error(
971
+ `mon: race ${rec.name}: invalid friends base ${f.name}`,
972
+ );
973
+ }
974
+ this.refused.push({
975
+ file: "monster",
976
+ record: rec.name,
977
+ field: "friends-base",
978
+ id: owner,
979
+ why: refusalWhy(
980
+ rec.name,
981
+ "friends-base entry dropped",
982
+ `unknown base ${f.name}`,
983
+ from,
984
+ ),
985
+ });
986
+ continue;
868
987
  }
869
988
  const num = parseNumberPair(f.number);
870
989
  friendsBase.push({
package/src/obj/bind.ts CHANGED
@@ -824,13 +824,34 @@ export class ObjRegistry {
824
824
  private bindCurses(records: CurseRecordJson[]): void {
825
825
  for (let r = records.length - 1; r >= 0; r--) {
826
826
  const rec = records[r] as CurseRecordJson;
827
+ const from = provenanceOf(rec);
827
828
  const poss: boolean[] = new Array<boolean>(TV_MAX).fill(false);
828
829
  for (const tvalName of rec.type ?? []) {
829
830
  const tval = tvalFindIdx(tvalName);
830
- if (tval < 0 || tval >= TV_MAX) {
831
+ if (tval >= 0 && tval < TV_MAX) {
832
+ poss[tval] = true;
833
+ continue;
834
+ }
835
+ /*
836
+ * `type:` NAMES A TVAL, from the same fixed table `object:`'s own
837
+ * `type:` line reads - not a record another pack could remove, but a
838
+ * mod can still misspell it on a curse it authors itself. Same
839
+ * whole-field granularity as the artifact `flags`/`values` fix above:
840
+ * one bad entry in the list is dropped, and whether it is core's
841
+ * mistake or a mod's is decided by whether anything touched `type` at
842
+ * all.
843
+ */
844
+ const owner = fieldOwner(from, "type", rec.type);
845
+ if (owner === null || from === undefined) {
831
846
  throw new Error(`curse: unknown tval ${tvalName}`);
832
847
  }
833
- poss[tval] = true;
848
+ this.refused.push({
849
+ file: "curse",
850
+ record: rec.name,
851
+ field: "type",
852
+ id: owner,
853
+ why: refusalWhy(rec.name, "type entry dropped", `unknown tval ${tvalName}`, from),
854
+ });
834
855
  }
835
856
  const objFlags = newOfFlags();
836
857
  const elInfo = newElemInfo();
@@ -1262,11 +1283,21 @@ export class ObjRegistry {
1262
1283
  * situation as that mod not being installed, which is what the player just
1263
1284
  * asked for.
1264
1285
  *
1265
- * NOTE what is NOT covered: an invalid `flags`, `values` or `act` token on a
1266
- * mod's artifact still throws. Those are worth doing and are not this change,
1267
- * because each one needs its loop turned into something that can report
1268
- * instead of throw, inside a binder that is parity-sensitive. base-object is
1269
- * the field the tutorial teaches and the one a disabled dependency breaks.
1286
+ * `flags` and `values` get the same treatment below, at the WHOLE-FIELD
1287
+ * granularity `curseWeightConflict` established rather than per store's
1288
+ * per-entry one: a `flags:` line can pack several tokens together
1289
+ * ("SEE_INVIS | IGNORE_ACID"), so there is no single list entry to match
1290
+ * against `was` the way a store's stock line or an ego's `item:` line
1291
+ * has. What IS answerable is whether the field as a whole is core's
1292
+ * untouched data or something a pack touched, and that is what decides
1293
+ * throw-versus-drop; the offending TOKEN is still named in the report.
1294
+ *
1295
+ * `act` is deliberately NOT covered, and that is not an omission: findact
1296
+ * (obj-init.c parse_artifact_act) never null-checks its own result either,
1297
+ * so an artifact whose `act:` names nothing already gets a powerless
1298
+ * activation upstream, silently, on core's own data. Refusing it here
1299
+ * would make a mod's typo LOUDER than the same mistake in core's own
1300
+ * artifact.txt, which is backwards.
1270
1301
  */
1271
1302
  const from = provenanceOf(rec);
1272
1303
  /* Restored on the refusal paths below: resolving an sval can APPEND a dummy
@@ -1305,7 +1336,18 @@ export class ObjRegistry {
1305
1336
  const flags = newOfFlags();
1306
1337
  for (const tok of tokens(rec.flags)) {
1307
1338
  const found = grabFlag(flags, OF, tok) || grabElementFlag(elInfo, tok);
1308
- if (!found) throw new Error(`artifact: invalid flag ${tok}`);
1339
+ if (found) continue;
1340
+ const owner = fieldOwner(from, "flags", rec.flags);
1341
+ if (owner === null || from === undefined) {
1342
+ throw new Error(`artifact: invalid flag ${tok}`);
1343
+ }
1344
+ this.refused.push({
1345
+ file: "artifact",
1346
+ record: rec.name,
1347
+ field: "flags",
1348
+ id: owner,
1349
+ why: refusalWhy(rec.name, "flag dropped", `invalid flag ${tok}`, from),
1350
+ });
1309
1351
  }
1310
1352
  const modifiers = new Array<number>(OBJ_MOD_MAX).fill(0);
1311
1353
  for (const tok of tokens(rec.values)) {
@@ -1316,7 +1358,19 @@ export class ObjRegistry {
1316
1358
  (elInfo[res.index] as ElementInfo).resLevel = res.value;
1317
1359
  found = true;
1318
1360
  }
1319
- if (!found) throw new Error(`artifact: invalid value ${tok}`);
1361
+ if (!found) {
1362
+ const owner = fieldOwner(from, "values", rec.values);
1363
+ if (owner === null || from === undefined) {
1364
+ throw new Error(`artifact: invalid value ${tok}`);
1365
+ }
1366
+ this.refused.push({
1367
+ file: "artifact",
1368
+ record: rec.name,
1369
+ field: "values",
1370
+ id: owner,
1371
+ why: refusalWhy(rec.name, "value dropped", `invalid value ${tok}`, from),
1372
+ });
1373
+ }
1320
1374
  }
1321
1375
  const special = kind.kidx >= this.ordinaryKindCount;
1322
1376
  if (rec.graphics) {
package/src/rng.ts CHANGED
@@ -395,7 +395,7 @@ export class Rng {
395
395
  * for setState() - which is the SAVEFILE path and deliberately forces quick
396
396
  * off (see its comment). A quick generator reseeded that way is handed an
397
397
  * all-zero WELL table, and an all-zero WELL state is a fixed point: it emits
398
- * zero forever. The Borg's per-think reseed did exactly this, and the reason
398
+ * zero forever. Borg's per-think reseed did exactly this, and the reason
399
399
  * it went unnoticed for so long is that its test asserted two generators
400
400
  * produced the SAME sequence, which a generator stuck at zero satisfies
401
401
  * perfectly.
@@ -987,12 +987,18 @@ function wireGame(
987
987
  rogueLike: state.options?.get("rogue_like_commands") ?? false,
988
988
  characterDungeon: true,
989
989
  ...(state.msg ? { msg: state.msg } : {}),
990
+ /* The partialStackMerge / packOverflowVictim seams (mod/hooks.ts). */
991
+ ...(state.modHooks ? { hooks: state.modHooks } : {}),
990
992
  });
991
993
  /* game-world.c:941-947: the C refreshes upkeep->inven[] before its
992
994
  * catch-all pack_overflow(NULL). This closure has the same live
993
995
  * calc_inventory inputs used by pickup and command paths, preserving the
994
996
  * earlier_object order of the derived gear.inven view. */
995
997
  state.overflowPack = (): void => {
998
+ /* GameState.gear.quiver as it stood before this recompute, for the
999
+ * packOverflowVictim seam - the one point that catches an inscription (or
1000
+ * any other note-only change) displacing an item out of the quiver. */
1001
+ const previousQuiver = [...(state.gear.quiver ?? [])];
996
1002
  const calcInv = liveCalcInv();
997
1003
  /* notice_stuff()/handle_stuff() precede pack_overflow(NULL) at
998
1004
  * game-world.c:941-947; materialize the current upkeep->inven[] analogue
@@ -1000,7 +1006,9 @@ function wireGame(
1000
1006
  calcInventory(state.gear, reg.constants, calcInv);
1001
1007
  packOverflow(state, 0, reg.constants, {
1002
1008
  calcInv,
1009
+ previousQuiver,
1003
1010
  ...(state.msg ? { msg: state.msg } : {}),
1011
+ ...(state.modHooks ? { hooks: state.modHooks } : {}),
1004
1012
  });
1005
1013
  };
1006
1014
  /* notice_stuff's PN_COMBINE branch (player-calcs.c L2546-2549). combine_pack
package/src/store/bind.ts CHANGED
@@ -58,7 +58,7 @@ import type {
58
58
  * list; all three lose a single entry. `store` is the entrance feature, which
59
59
  * is a scalar and costs the whole shop.
60
60
  */
61
- export type StoreField = "normal" | "always" | "buy" | "store";
61
+ export type StoreField = "normal" | "always" | "buy" | "store" | "owner";
62
62
 
63
63
  /**
64
64
  * The entrance feature of a store whose `store:` a mod pointed at a feature
@@ -111,6 +111,7 @@ function resolveKind(item: StoreItemJson, reg: ObjRegistry): StockResolution {
111
111
  function lostWhat(field: StoreField): string {
112
112
  if (field === "store") return "shop cannot be entered";
113
113
  if (field === "buy") return "buy list entry dropped";
114
+ if (field === "owner") return "owner list dropped";
114
115
  return `${field} stock line dropped`;
115
116
  }
116
117
 
@@ -159,12 +160,6 @@ export function bindStore(
159
160
  reg: ObjRegistry,
160
161
  refused?: RecordRefusal[],
161
162
  ): BoundStore {
162
- const owners: StoreOwner[] = rec.owner.map((o, index) => ({
163
- index,
164
- name: o.name,
165
- maxCost: o.purse,
166
- }));
167
-
168
163
  const from = provenanceOf(rec);
169
164
  /** One refusal for this record, so four call sites cannot disagree. */
170
165
  const refusal = (
@@ -179,6 +174,43 @@ export function bindStore(
179
174
  id,
180
175
  why: refusalWhy(rec.store, lostWhat(field), why, prov),
181
176
  });
177
+
178
+ /*
179
+ * `owner:` IS A REQUIRED LIST, and unlike every other field this binder
180
+ * refuses, nothing inside it names another record - MOD_COMPATIBILITY.md
181
+ * already says as much: "the owner list resolves no names at all and so has
182
+ * nothing to refuse". What that line did not cover is the field going
183
+ * missing ENTIRELY. `replaces` on a record a mod owns can legitimately drop
184
+ * fields the replacing body means to remove - that is how a total
185
+ * conversion works, and composeContentPacks's own shape guard
186
+ * (mod-sdk/compose.ts, `unreadableShape`) deliberately does not restore a
187
+ * field that is simply ABSENT, because "gone" and "total conversion left it
188
+ * out on purpose" are indistinguishable from the composer's side. So a
189
+ * `replaces` body that forgets `owner:` on a record a mod owns reaches this
190
+ * line with `rec.owner` undefined, and `rec.owner.map` was a bare
191
+ * TypeError with no pack named - the same crash this file exists to close,
192
+ * one field up from a stock line.
193
+ *
194
+ * AN EMPTY OWNER LIST IS THE SIZE OF THE DROP: the store keeps functioning
195
+ * (turnover, stock, buy) with nobody to haggle against, which is a strictly
196
+ * smaller loss than the shop vanishing or the boot failing.
197
+ */
198
+ const ownerRecords = Array.isArray(rec.owner) ? rec.owner : null;
199
+ let owners: StoreOwner[];
200
+ if (ownerRecords) {
201
+ owners = ownerRecords.map((o, index) => ({
202
+ index,
203
+ name: o.name,
204
+ maxCost: o.purse,
205
+ }));
206
+ } else {
207
+ const owner = fieldOwner(from, "owner", rec.owner);
208
+ if (owner === null || from === undefined) {
209
+ throw new Error(`store: ${rec.store}: owner is missing or not a list`);
210
+ }
211
+ refused?.push(refusal("owner", "owner list missing or not a list", owner, from));
212
+ owners = [];
213
+ }
182
214
  /**
183
215
  * One stock line, or null when it resolved to nothing and was a mod's to get
184
216
  * wrong. A core-owned miss throws here with exactly the message it always
package/src/version.ts CHANGED
@@ -13,14 +13,15 @@ export const PARITY_BASELINE = "4.2.6";
13
13
  /**
14
14
  * Port version, tracked independently of the baseline.
15
15
  *
16
- * Semver, and 0.x is the pre-release line: a feature release bumps the MINOR
17
- * number, so 0.9.0 is followed by 0.10.0 rather than by 1.0.0. That is worth
18
- * stating because the first reading of "0.9.0" is "nearly 1.0", and it is not -
19
- * 1.0.0 is reserved for the public release and the line can run as far as it needs
20
- * to before then.
16
+ * Standard Semantic Versioning as of 1.0.0, the public release: a breaking
17
+ * change to the API, save format, or mod interfaces is a MAJOR bump, a
18
+ * backward-compatible feature is MINOR, and a fix is PATCH. Before this,
19
+ * `0.x` was the pre-release line, where every feature release bumped the
20
+ * MINOR number instead (0.9.0 was followed by 0.10.0 rather than 1.0.0).
21
21
  *
22
22
  * Each mod carries its own version and moves on its own schedule; a mod whose
23
- * released tag is iterated takes a MINOR bump, because a published tag is pinned
24
- * by digest in a catalogue and must never be moved.
23
+ * released tag needs to be superseded takes whatever bump its actual change
24
+ * warrants, because a published tag is pinned by digest in a catalogue and
25
+ * must never be moved.
25
26
  */
26
- export const ENGINE_VERSION = "0.34.2";
27
+ export const ENGINE_VERSION = "1.0.0";
@@ -10,7 +10,9 @@
10
10
  import { FlagSet } from "../bitflag.js";
11
11
  import { FEAT, RF, TERRAIN_ENTRIES, TERRAIN_FLAG_ENTRIES, TF } from "../generated/index.js";
12
12
  import type { ModExtensible } from "../mod/extension.js";
13
- import { attachExt } from "../mod/extension.js";
13
+ import { attachExt, provenanceOf } from "../mod/extension.js";
14
+ import { fieldOwner, refusalWhy } from "../mod/refusal.js";
15
+ import type { RecordRefusal } from "../mod/refusal.js";
14
16
 
15
17
  /** Byte size of a terrain FlagSet (upstream TF_SIZE). */
16
18
  export const TF_SIZE = Math.ceil(TERRAIN_FLAG_ENTRIES.length / 8);
@@ -155,6 +157,12 @@ export class FeatureRegistry {
155
157
  /** fidx of each shop entrance, indexed by store number (shopnum - 1). */
156
158
  private shopFeatIdx: number[] = [];
157
159
 
160
+ /**
161
+ * Mod-owned terrain records whose `mimic:` named a feature that does not
162
+ * resolve. Empty for the shipped pack with no mods loaded.
163
+ */
164
+ readonly refused: RecordRefusal[] = [];
165
+
158
166
  constructor(records: TerrainRecordJson[]) {
159
167
  const featMap = FEAT as Record<string, number>;
160
168
  for (const rec of records) {
@@ -188,14 +196,44 @@ export class FeatureRegistry {
188
196
  this.byCode.set(rec.code, feature);
189
197
  this.byName.set(rec.name, feature);
190
198
  }
191
- // Second pass: resolve mimic references by code.
199
+ /*
200
+ * Second pass: resolve mimic references by code.
201
+ *
202
+ * `mimic:` NAMES ANOTHER TERRAIN RECORD, and a mod's terrain.json entries
203
+ * are exactly the mod-appendable list `code:` and every other lookup here
204
+ * cannot be: FEAT codes are the fixed table list-terrain.h compiles, so a
205
+ * mod cannot invent a new one, but it CAN patch an existing feature's
206
+ * `mimic:` to point at a feature a sibling mod supplies - the same
207
+ * "mod A depends on mod B, player disables mod B" shape the store and ego
208
+ * binders already answer. This used to throw `terrain: mimic not found`
209
+ * out of `bindCore` for the whole game over one feature's display alias.
210
+ *
211
+ * LOSING THE MIMIC IS THE SIZE OF THE DROP, matching the store binder's
212
+ * entrance-feature case: `mimic` is a scalar (one feature, or none), so
213
+ * there is no smaller unit to drop than the field itself, and the record
214
+ * survives exactly as it would if `mimic:` had never been written -
215
+ * displayed with its own glyph rather than the target's.
216
+ */
192
217
  for (const rec of records) {
193
- if (rec.mimic !== undefined) {
194
- const f = this.byCode.get(rec.code) as Feature;
195
- const target = this.byCode.get(rec.mimic);
196
- if (!target) throw new Error(`terrain: mimic not found: ${rec.mimic}`);
218
+ if (rec.mimic === undefined) continue;
219
+ const f = this.byCode.get(rec.code) as Feature;
220
+ const target = this.byCode.get(rec.mimic);
221
+ if (target) {
197
222
  f.mimic = target.fidx;
223
+ continue;
224
+ }
225
+ const from = provenanceOf(rec);
226
+ const owner = fieldOwner(from, "mimic", rec.mimic);
227
+ if (owner === null || from === undefined) {
228
+ throw new Error(`terrain: mimic not found: ${rec.mimic}`);
198
229
  }
230
+ this.refused.push({
231
+ file: "terrain",
232
+ record: rec.code,
233
+ field: "mimic",
234
+ id: owner,
235
+ why: refusalWhy(rec.code, "mimic dropped", `mimic not found: ${rec.mimic}`, from),
236
+ });
199
237
  }
200
238
  /*
201
239
  * finish_parse_feat (init.c L2249-2257, L2275): "Assign shop index based