@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.
- package/README.md +6 -4
- package/dist/agent/entity-views.d.ts.map +1 -1
- package/dist/agent/entity-views.js +14 -0
- package/dist/agent/entity-views.js.map +1 -1
- package/dist/agent/types.d.ts +39 -1
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js +11 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/game/gear.d.ts +6 -0
- package/dist/game/gear.d.ts.map +1 -1
- package/dist/game/gear.js +15 -3
- package/dist/game/gear.js.map +1 -1
- package/dist/game/obj-cmd.d.ts +19 -0
- package/dist/game/obj-cmd.d.ts.map +1 -1
- package/dist/game/obj-cmd.js +32 -2
- package/dist/game/obj-cmd.js.map +1 -1
- package/dist/game/wizard.d.ts +1 -1
- package/dist/game/wizard.js +1 -1
- package/dist/mod/hooks.d.ts +57 -6
- package/dist/mod/hooks.d.ts.map +1 -1
- package/dist/mod/hooks.js +39 -0
- package/dist/mod/hooks.js.map +1 -1
- package/dist/mod/save-blocks.d.ts +1 -1
- package/dist/mod/save-blocks.d.ts.map +1 -1
- package/dist/mon/bind.d.ts +17 -0
- package/dist/mon/bind.d.ts.map +1 -1
- package/dist/mon/bind.js +115 -8
- package/dist/mon/bind.js.map +1 -1
- package/dist/obj/bind.d.ts.map +1 -1
- package/dist/obj/bind.js +63 -10
- package/dist/obj/bind.js.map +1 -1
- package/dist/rng.d.ts +1 -1
- package/dist/rng.js +1 -1
- package/dist/session/game.d.ts.map +1 -1
- package/dist/session/game.js +8 -0
- package/dist/session/game.js.map +1 -1
- package/dist/store/bind.d.ts +1 -1
- package/dist/store/bind.d.ts.map +1 -1
- package/dist/store/bind.js +39 -5
- package/dist/store/bind.js.map +1 -1
- package/dist/version.d.ts +9 -8
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +9 -8
- package/dist/version.js.map +1 -1
- package/dist/world/feature.d.ts +6 -0
- package/dist/world/feature.d.ts.map +1 -1
- package/dist/world/feature.js +43 -7
- package/dist/world/feature.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/entity-views.ts +14 -0
- package/src/agent/types.ts +39 -1
- package/src/game/gear.ts +22 -3
- package/src/game/obj-cmd.ts +47 -2
- package/src/game/wizard.ts +1 -1
- package/src/mod/hooks.ts +105 -6
- package/src/mon/bind.ts +129 -10
- package/src/obj/bind.ts +63 -9
- package/src/rng.ts +1 -1
- package/src/session/game.ts +8 -0
- package/src/store/bind.ts +39 -7
- package/src/version.ts +9 -8
- 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
|
|
350
|
-
* and stop at the first non-null, so the last mod to
|
|
351
|
-
* one whose handling takes effect and no earlier mod
|
|
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)
|
|
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
|
-
/*
|
|
670
|
-
|
|
671
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
866
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
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 (
|
|
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)
|
|
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.
|
|
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.
|
package/src/session/game.ts
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
24
|
-
* by digest in a catalogue and
|
|
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.
|
|
27
|
+
export const ENGINE_VERSION = "1.0.0";
|
package/src/world/feature.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|