@rpgm-tools/neo-angband-core 0.33.0 → 0.34.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/src/mod/hooks.ts CHANGED
@@ -72,6 +72,7 @@
72
72
 
73
73
  import type { GameState } from "../game/context.js";
74
74
  import type { OptionStateData } from "../player/options.js";
75
+ import type { Chunk } from "../world/chunk.js";
75
76
 
76
77
  /**
77
78
  * A history entry offered to the write seam.
@@ -251,6 +252,52 @@ export interface ModHooks {
251
252
  */
252
253
  saveNoiseScent?: () => boolean;
253
254
 
255
+ /**
256
+ * A previously frozen level has been restored as the live level
257
+ * (session/game.ts' persistent-level and single-combat return paths).
258
+ *
259
+ * `frozenAt` is the game turn when the level was put aside and `now` is the
260
+ * game turn at which it came back. Both are supplied rather than a rounded
261
+ * elapsed count: callers that model once-per-world-tick work need the same
262
+ * boundary calculation as the engine's `turn % 10 === 0` scheduler.
263
+ *
264
+ * This is a notification. Core changes none of the restored level's state
265
+ * when it is absent, preserving 4.2.6 exactly; a mod may adjust per-level
266
+ * transient state such as tracking heatmaps.
267
+ *
268
+ * Serves: the upstream-catchup mod's post-4.2.6 noise clear and scent aging
269
+ * (upstream 5c45eb9588b8227d4f1b1998e0a627ad7ee11a75, #4605).
270
+ */
271
+ levelRevisited?: (chunk: Chunk, frozenAt: number, now: number) => void;
272
+
273
+ /**
274
+ * Whether a shapechange's obvious flags should be learned directly as
275
+ * runes (obj/knowledge.ts, shapeLearnOnAssume's per-flag loop, reached from
276
+ * the EF_SHAPECHANGE handler).
277
+ *
278
+ * Return true to learn them directly, ON TOP OF the existing equipment-based
279
+ * `equipLearnFlag` call rather than instead of it - a flag both the shape and
280
+ * a worn item carry still fires the worn-item message unchanged. Faithful
281
+ * core never does this: `shapeLearnOnAssume` only ever learns a shape's
282
+ * obvious flag through `equipLearnFlag`, which requires a WORN item to carry
283
+ * the flag too, so a flag the shape alone grants is never learned. That is
284
+ * the 4.2.6-era gap this mod exists to catalogue, not a bug this port
285
+ * introduced.
286
+ *
287
+ * Core still computes and restricts to the exact SAME obvious-flag set it
288
+ * always has - `shape.flags` intersected with the OFID_WIELD mask,
289
+ * `shapeLearnOnAssume`'s existing first two lines. This hook decides only
290
+ * WHETHER that already-computed set is learned directly, never WHICH flags
291
+ * are in it, so a mod cannot widen this seam into "learn everything the
292
+ * shape has" - the same shortcut this project already reverted once for
293
+ * runes broadly (commit 7970af462, docs/modding/BUG_FIXES.md's "port's own
294
+ * code" section).
295
+ *
296
+ * Serves: the upstream-catchup mod's shape-obvious-flag learning (upstream
297
+ * c8036c51537942a560e3d7f81749c431bbb4701f).
298
+ */
299
+ shapeLearnObviousFlagsDirectly?: () => boolean;
300
+
254
301
  /**
255
302
  * Player-visible message text, on its way to the message line (the host's
256
303
  * message sink).
@@ -267,9 +314,8 @@ export interface ModHooks {
267
314
  /**
268
315
  * The player finished changing their options (the '=' menu closed).
269
316
  *
270
- * A NOTIFICATION, and the only one here: every other hook is asked a question
271
- * and its answer changes what the engine does, whereas this one is told a
272
- * thing that already happened and core does not read the return value. The
317
+ * A NOTIFICATION: like levelRevisited, it is told a thing that already
318
+ * happened and core does not read a return value. The
273
319
  * fold is "all-observe" for that reason - there is no answer to pick between,
274
320
  * so every contributor simply runs.
275
321
  *
@@ -313,8 +359,11 @@ export interface ModHooks {
313
359
  * or over the radius the blast is built from.
314
360
  * - VETO hooks (levelGenerated, artifactCommit, historyAdd) are conjunctive:
315
361
  * every contributor runs and any refusal decides.
316
- * - ANY hooks (saveNoiseScent) are disjunctive: one mod asking for the data is
317
- * enough, because the data is additive and a second mod cannot object.
362
+ * - ANY hooks (saveNoiseScent, shapeLearnObviousFlagsDirectly) are disjunctive:
363
+ * one mod asking for the data is enough, because the data is additive and a
364
+ * second mod cannot object.
365
+ * - NOTIFICATION hooks (optionsChanged, levelRevisited) call every contributor
366
+ * in load order. There is no answer for a later mod to override.
318
367
  *
319
368
  * WHY THE LAST TWO ARE NOT EXCEPTIONS. "Later wins" answers the question "two
320
369
  * mods disagree about one thing - whose answer is used?", and a veto hook is not
@@ -370,6 +419,8 @@ export const MOD_HOOK_FOLDS: Readonly<Record<keyof ModHooks, ModHookFold>> = {
370
419
  historyAdd: "all-must-agree",
371
420
  historyDisplay: "chained",
372
421
  saveNoiseScent: "any-yes",
422
+ shapeLearnObviousFlagsDirectly: "any-yes",
423
+ levelRevisited: "all-observe",
373
424
  messageText: "chained",
374
425
  optionsChanged: "all-observe",
375
426
  };
@@ -500,6 +551,22 @@ export function guardModHooks(
500
551
  out.saveNoiseScent = (): boolean => guard("saveNoiseScent", () => noise(), false);
501
552
  }
502
553
 
554
+ const shapeFlags = hooks.shapeLearnObviousFlagsDirectly;
555
+ if (shapeFlags) {
556
+ /* DECLINE, the upstream 4.2.6-era gap this seam exists to let a mod close. */
557
+ out.shapeLearnObviousFlagsDirectly = (): boolean =>
558
+ guard("shapeLearnObviousFlagsDirectly", () => shapeFlags(), false);
559
+ }
560
+
561
+ const revisited = hooks.levelRevisited;
562
+ if (revisited) {
563
+ /* A throwing observer changes nothing: faithful core resumes the frozen
564
+ * level as-is, and the host receives the fault. */
565
+ out.levelRevisited = (chunk, frozenAt, now): void => {
566
+ guard("levelRevisited", () => revisited(chunk, frozenAt, now), undefined);
567
+ };
568
+ }
569
+
503
570
  const text = hooks.messageText;
504
571
  if (text) {
505
572
  /* The raw message, unrestated - never an empty string, which would silently
@@ -611,6 +678,23 @@ export function composeModHooks(
611
678
  out.saveNoiseScent = (): boolean => noise.some((fn) => fn());
612
679
  }
613
680
 
681
+ const shapeFlags = list.map((c) => c.shapeLearnObviousFlagsDirectly).filter(isFn);
682
+ if (shapeFlags.length > 0) {
683
+ /* One mod asking for direct learning is enough - two mods both wanting the
684
+ * 4.2.6-era gap closed are not in conflict, they are two mods agreeing. */
685
+ out.shapeLearnObviousFlagsDirectly = (): boolean => shapeFlags.some((fn) => fn());
686
+ }
687
+
688
+ const revisited = list.map((c) => c.levelRevisited).filter(isFn);
689
+ if (revisited.length > 0) {
690
+ /* Like optionsChanged, every contributor observes the same resume event.
691
+ * The chunk is intentionally live: this seam exists for a mod to update
692
+ * transient, level-owned state before monsters receive another turn. */
693
+ out.levelRevisited = (chunk, frozenAt, now): void => {
694
+ for (const fn of revisited) fn(chunk, frozenAt, now);
695
+ };
696
+ }
697
+
614
698
  const text = list.map((c) => c.messageText).filter(isFn);
615
699
  if (text.length > 0) {
616
700
  out.messageText = (raw): string => text.reduce((s, fn) => fn(s), raw);
@@ -798,11 +798,26 @@ export function equipLearnElement(p: Player, env: RuneEnv, element: number): voi
798
798
  * shape_learn_on_assume (L1892): on taking a shape, learn its obvious
799
799
  * (OFID_WIELD) flags and its resists, through the same equipment learn
800
800
  * calls upstream uses.
801
+ *
802
+ * `learnObviousFlagsDirectly` is the ModHooks.shapeLearnObviousFlagsDirectly
803
+ * seam's resolved answer, threaded in by the caller (effect-general.ts'
804
+ * EF_SHAPECHANGE handler, the only one with a GameState to read a mod hook
805
+ * from - this module stays state-free, like the rest of obj/knowledge.ts).
806
+ * Faithful core passes `false`: a shape's obvious flag is then learned only
807
+ * through `equipLearnFlag`, which requires a WORN item to also carry the
808
+ * flag, so a flag the shape alone grants is never learned - the 4.2.6-era
809
+ * gap upstream's own `c8036c51537942a560e3d7f81749c431bbb4701f` fixed by
810
+ * learning the same already-computed set directly as runes instead. When
811
+ * true, this does exactly that IN ADDITION to the existing equipment call
812
+ * per flag: `playerLearnFlagRune` is a no-op for a flag already learned by
813
+ * the equipment path, so a flag the shape and a worn item both carry still
814
+ * fires the worn-item's own message and nothing here duplicates it.
801
815
  */
802
816
  export function shapeLearnOnAssume(
803
817
  p: Player,
804
818
  env: RuneEnv,
805
819
  shape: import("../player/types.js").Shape,
820
+ learnObviousFlagsDirectly = false,
806
821
  ): void {
807
822
  /* Get the shape's obvious flags. */
808
823
  const f = shape.flags.clone();
@@ -811,6 +826,7 @@ export function shapeLearnOnAssume(
811
826
  /* Learn flags. */
812
827
  for (let flag = f.next(FLAG_START); flag !== NO_FLAG; flag = f.next(flag + 1)) {
813
828
  equipLearnFlag(p, env, flag);
829
+ if (learnObviousFlagsDirectly) playerLearnFlagRune(p, env, flag);
814
830
  }
815
831
 
816
832
  /* Learn elements. */
@@ -2659,6 +2659,11 @@ function makeChangeLevel(
2659
2659
  state.traps = stash.traps;
2660
2660
  state.known = stash.known;
2661
2661
  if (stash.decoy) state.decoy = stash.decoy;
2662
+ /* A level restored after single combat is the same kind of revisit as
2663
+ * one recovered from birth_levels_persist. Faithful core leaves every
2664
+ * field untouched; an opt-in mod can update transient per-level state
2665
+ * before the player and its monsters resume. */
2666
+ state.modHooks?.levelRevisited?.(state.chunk, stash.turn, state.turn);
2662
2667
  const back = state.oldGrid ?? state.actor.grid;
2663
2668
  state.actor.grid = back;
2664
2669
  state.chunk.setMon(back, -1);
@@ -2788,6 +2793,12 @@ function makeChangeLevel(
2788
2793
  * the turns the level was frozen (turn - chunk->freeze-turn). */
2789
2794
  restoreMonsters(state, state.turn - stored.turn);
2790
2795
 
2796
+ /* A restored persistent level is live again. Core deliberately does
2797
+ * nothing here, matching 4.2.6; a mod may observe the exact freeze and
2798
+ * resume turns to update level-owned transient state before any monster
2799
+ * can receive a turn. */
2800
+ state.modHooks?.levelRevisited?.(state.chunk, stored.turn, state.turn);
2801
+
2791
2802
  /* Place the player (prepare_next_level non-arena, L1497-1501): sanitize
2792
2803
  * the retained grid into a legal arrival square, then player_place.
2793
2804
  * DEFERRAL: exact stair-connector matching (dun->persist / one_off_*
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.33.0";
26
+ export const ENGINE_VERSION = "0.34.0";