@rpgm-tools/neo-angband-core 0.24.0 → 0.26.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 (64) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/entity-views.d.ts +62 -0
  3. package/dist/agent/entity-views.d.ts.map +1 -0
  4. package/dist/agent/entity-views.js +195 -0
  5. package/dist/agent/entity-views.js.map +1 -0
  6. package/dist/agent/index.d.ts +5 -1
  7. package/dist/agent/index.d.ts.map +1 -1
  8. package/dist/agent/index.js +2 -0
  9. package/dist/agent/index.js.map +1 -1
  10. package/dist/agent/loadout.d.ts +97 -0
  11. package/dist/agent/loadout.d.ts.map +1 -0
  12. package/dist/agent/loadout.js +312 -0
  13. package/dist/agent/loadout.js.map +1 -0
  14. package/dist/agent/perceive.d.ts.map +1 -1
  15. package/dist/agent/perceive.js +16 -162
  16. package/dist/agent/perceive.js.map +1 -1
  17. package/dist/agent/types.d.ts +167 -2
  18. package/dist/agent/types.d.ts.map +1 -1
  19. package/dist/agent/types.js +10 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/game/context.d.ts +15 -0
  22. package/dist/game/context.d.ts.map +1 -1
  23. package/dist/game/context.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mod/registry-host.d.ts +97 -0
  29. package/dist/mod/registry-host.d.ts.map +1 -1
  30. package/dist/mod/registry-host.js +7 -0
  31. package/dist/mod/registry-host.js.map +1 -1
  32. package/dist/mod/save-blocks.d.ts +1 -1
  33. package/dist/player/calcs.d.ts +9 -0
  34. package/dist/player/calcs.d.ts.map +1 -1
  35. package/dist/player/calcs.js +13 -1
  36. package/dist/player/calcs.js.map +1 -1
  37. package/dist/player/loadout.d.ts +178 -0
  38. package/dist/player/loadout.d.ts.map +1 -0
  39. package/dist/player/loadout.js +172 -0
  40. package/dist/player/loadout.js.map +1 -0
  41. package/dist/player/spell.d.ts +21 -1
  42. package/dist/player/spell.d.ts.map +1 -1
  43. package/dist/player/spell.js +53 -14
  44. package/dist/player/spell.js.map +1 -1
  45. package/dist/session/game.d.ts +39 -0
  46. package/dist/session/game.d.ts.map +1 -1
  47. package/dist/session/game.js +201 -31
  48. package/dist/session/game.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +1 -1
  52. package/src/agent/entity-views.ts +227 -0
  53. package/src/agent/index.ts +9 -0
  54. package/src/agent/loadout.ts +400 -0
  55. package/src/agent/perceive.ts +22 -168
  56. package/src/agent/types.ts +166 -2
  57. package/src/game/context.ts +18 -0
  58. package/src/index.ts +1 -0
  59. package/src/mod/registry-host.ts +108 -0
  60. package/src/player/calcs.ts +22 -1
  61. package/src/player/loadout.ts +331 -0
  62. package/src/player/spell.ts +67 -16
  63. package/src/session/game.ts +287 -35
  64. package/src/version.ts +1 -1
@@ -15,28 +15,25 @@
15
15
  */
16
16
 
17
17
  import {
18
- ELEMENT_ENTRIES,
19
18
  FEAT,
20
19
  MON_RACE_FLAG_ENTRIES,
21
20
  MON_SPELL_ENTRIES,
22
21
  MON_TMD,
23
- OBJECT_FLAG_ENTRIES,
24
22
  SQUARE,
25
- TMD,
26
23
  TRF,
27
24
  } from "../generated/index.js";
28
25
  import type { FlagSet } from "../bitflag.js";
29
26
  import { useFlavorGlyph } from "../visuals/object-glyph.js";
30
27
  import type { GameState } from "../game/context.js";
31
28
  import { gearGet } from "../game/gear.js";
32
- import type { GameObject } from "../obj/object.js";
33
29
  import { LIGHTING } from "../visuals/tile-prefs.js";
34
- import { OBJ_MOD_NAMES } from "../obj/bind.js";
35
- import { objectValue } from "../obj/value.js";
36
30
  import { monsterIsVisible } from "../mon/predicate.js";
37
31
  import { PY_SPELL, spellChance } from "../player/spell.js";
38
32
  import { makeSpellChanceEnv } from "../game/spell-cmd.js";
39
33
  import { priceItem } from "../store/price.js";
34
+ import { squareIsDisarmableTrap } from "../game/trap.js";
35
+ import { itemView, playerViewFor } from "./entity-views.js";
36
+ import { simulateLoadout } from "./loadout.js";
40
37
  import { AGENT_API_VERSION, AGENT_STATE_DOMAINS, AgentCapabilityError } from "./types.js";
41
38
  import type {
42
39
  AgentCapabilities,
@@ -45,8 +42,9 @@ import type {
45
42
  AgentViewDeps,
46
43
  CellView,
47
44
  ItemView,
45
+ LoadoutChange,
46
+ LoadoutSimulation,
48
47
  MonsterView,
49
- PlayerView,
50
48
  SpellbookView,
51
49
  SpellView,
52
50
  StoreItemView,
@@ -54,16 +52,6 @@ import type {
54
52
  TargetView,
55
53
  } from "./types.js";
56
54
 
57
- /** OF_* codes for the set flags in an object-flag FlagSet (OF is 1-indexed). */
58
- function ofCodes(flags: FlagSet): string[] {
59
- const out: string[] = [];
60
- for (const f of flags) {
61
- const entry = OBJECT_FLAG_ENTRIES[f - 1];
62
- if (entry) out.push(entry.name);
63
- }
64
- return out;
65
- }
66
-
67
55
  /** RF_* codes for the set flags in a race-flag FlagSet (entry index == RF value). */
68
56
  function raceFlagCodes(flags: FlagSet): string[] {
69
57
  const out: string[] = [];
@@ -84,155 +72,6 @@ function spellFlagCodes(flags: FlagSet): string[] {
84
72
  return out;
85
73
  }
86
74
 
87
- function itemView(
88
- handle: number,
89
- obj: GameObject,
90
- state: GameState,
91
- deps: AgentViewDeps,
92
- ): ItemView {
93
- const modifiers: Array<{ code: string; value: number }> = [];
94
- for (let i = 0; i < obj.modifiers.length; i++) {
95
- const value = obj.modifiers[i] ?? 0;
96
- if (value === 0) continue;
97
- const code = OBJ_MOD_NAMES[i];
98
- if (code) modifiers.push({ code, value });
99
- }
100
-
101
- const brands: string[] = [];
102
- if (obj.brands) {
103
- for (let i = 0; i < obj.brands.length; i++) {
104
- if (!obj.brands[i]) continue;
105
- const code = state.brands[i]?.code;
106
- if (code) brands.push(code);
107
- }
108
- }
109
-
110
- const slays: string[] = [];
111
- if (obj.slays) {
112
- for (let i = 0; i < obj.slays.length; i++) {
113
- if (!obj.slays[i]) continue;
114
- const code = state.slays[i]?.code;
115
- if (code) slays.push(code);
116
- }
117
- }
118
-
119
- const resists: Array<{ element: string; level: number }> = [];
120
- for (let i = 0; i < obj.elInfo.length; i++) {
121
- const level = obj.elInfo[i]?.resLevel ?? 0;
122
- if (level === 0) continue;
123
- const name = ELEMENT_ENTRIES[i]?.name;
124
- if (name) resists.push({ element: name, level });
125
- }
126
-
127
- const curses: string[] = [];
128
- if (obj.curses) {
129
- for (let i = 0; i < obj.curses.length; i++) {
130
- const power = obj.curses[i]?.power ?? 0;
131
- if (power <= 0) continue;
132
- /* Curse names resolve from the always-present RuneEnv curse table (real
133
- * in production, inert [null] in the worldless harness), then the
134
- * optional registry dep, then the numeric index as a last resort. */
135
- curses.push(
136
- state.runeEnv.curses[i]?.name ??
137
- deps.reg?.curses[i]?.name ??
138
- String(i),
139
- );
140
- }
141
- }
142
-
143
- const view: ItemView = {
144
- handle,
145
- label: obj.kind.name,
146
- tval: obj.tval,
147
- sval: obj.sval,
148
- pval: obj.pval,
149
- number: obj.number,
150
- weight: obj.weight,
151
- ac: obj.ac,
152
- toA: obj.toA,
153
- toH: obj.toH,
154
- toD: obj.toD,
155
- dd: obj.dd,
156
- ds: obj.ds,
157
- ego: obj.ego !== null,
158
- artifact: obj.artifact !== null,
159
- flags: ofCodes(obj.flags),
160
- modifiers,
161
- brands,
162
- slays,
163
- resists,
164
- curses,
165
- egoName: obj.ego?.name ?? null,
166
- artifactName: obj.artifact?.name ?? null,
167
- activation: obj.activation !== null,
168
- timeout: obj.timeout,
169
- inscription: obj.note ?? null,
170
- };
171
- if (deps.resolver) {
172
- const kindId = deps.resolver.kindIdOrNull(obj.kind.kidx);
173
- if (kindId !== null) view.kindId = kindId;
174
- }
175
- if (deps.reg) {
176
- const aware = deps.aware ?? ((): boolean => true);
177
- view.value = objectValue(deps.reg, obj, obj.number, aware(obj.kind));
178
- }
179
- return view;
180
- }
181
-
182
- function playerView(state: GameState, deps: AgentViewDeps): PlayerView {
183
- const p = state.actor.player;
184
- const combat = state.actor.combat;
185
- const playerState = state.playerState;
186
- const view: PlayerView = {
187
- race: p.race.name,
188
- cls: p.cls.name,
189
- level: p.lev,
190
- maxLevel: p.maxLev,
191
- exp: p.exp,
192
- maxExp: p.maxExp,
193
- gold: p.au,
194
- depth: state.chunk.depth,
195
- maxDepth: p.maxDepth,
196
- hp: p.chp,
197
- maxHp: p.mhp,
198
- sp: p.csp,
199
- maxSp: p.msp,
200
- speed: state.actor.speed,
201
- /* Displayed AC is state->ac + state->to_a. */
202
- ac: combat.ac + combat.toA,
203
- toHit: combat.toH,
204
- toDam: combat.toD,
205
- stats: [...p.statCur],
206
- light: state.actor.light,
207
- grid: { x: state.actor.grid.x, y: state.actor.grid.y },
208
- status: {
209
- blind: p.timed[TMD.BLIND] ?? 0,
210
- confused: p.timed[TMD.CONFUSED] ?? 0,
211
- afraid: p.timed[TMD.AFRAID] ?? 0,
212
- poisoned: p.timed[TMD.POISONED] ?? 0,
213
- cut: p.timed[TMD.CUT] ?? 0,
214
- stun: p.timed[TMD.STUN] ?? 0,
215
- paralyzed: p.timed[TMD.PARALYZED] ?? 0,
216
- food: p.timed[TMD.FOOD] ?? 0,
217
- },
218
- dead: state.isDead,
219
- winner: p.totalWinner,
220
- skills: [...p.skills],
221
- shape: p.shape?.name ?? null,
222
- objectFlags: playerState ? ofCodes(playerState.flags) : [],
223
- seeInfra: playerState?.seeInfra ?? p.race.infravision,
224
- blows: combat.numBlows,
225
- shots: combat.numShots,
226
- };
227
- if (deps.resolver) {
228
- const raceId = deps.resolver.playerRaceIdOrNull(p.race.ridx);
229
- if (raceId !== null) view.playerRaceId = raceId;
230
- const classId = deps.resolver.playerClassIdOrNull(p.cls.cidx);
231
- if (classId !== null) view.playerClassId = classId;
232
- }
233
- return view;
234
- }
235
-
236
75
  function monsterViews(state: GameState, deps: AgentViewDeps): MonsterView[] {
237
76
  const out: MonsterView[] = [];
238
77
  for (let i = 1; i < state.monsters.length; i++) {
@@ -291,7 +130,11 @@ function cellView(
291
130
  monster: c.mon(grid),
292
131
  objectCount: (state.floor.get(idx) ?? []).length,
293
132
  glow: c.sqinfoHas(grid, SQUARE["GLOW"]),
294
- trap: (state.traps.get(idx)?.length ?? 0) > 0,
133
+ /* square_isdisarmabletrap, not "the trap list is non-empty": a closed door's
134
+ * lock, a glyph of warding, a web and a decoy are all trap records, and none
135
+ * of them is a trap the player sees or the disarm command will act on. See
136
+ * CellView.trap. */
137
+ trap: squareIsDisarmableTrap(state, grid),
295
138
  };
296
139
  if (deps.resolver) {
297
140
  const code = deps.resolver.featIdOrNull(feat);
@@ -463,7 +306,7 @@ export function createAgentView(
463
306
  return {
464
307
  apiVersion: AGENT_API_VERSION,
465
308
  turn: gateRead(caps, D.turn, () => state.turn),
466
- player: gateRead(caps, D.player, () => playerView(state, deps)),
309
+ player: gateRead(caps, D.player, () => playerViewFor(state, deps)),
467
310
  monsters: gateRead(caps, D.monsters, () => monsterViews(state, deps)),
468
311
  cell: gateRead(caps, D.map, (x: number, y: number) =>
469
312
  cellView(state, x, y, deps),
@@ -500,5 +343,16 @@ export function createAgentView(
500
343
  stores: gateRead(caps, D.stores, () => storeViews(state, deps)),
501
344
  spellbooks: gateRead(caps, D.spells, () => spellbookViews(state)),
502
345
  constants: gateRead(caps, D.constants, () => ({ ...state.z })),
346
+ /* The same deps as every accessor above, on purpose: an ItemView from a
347
+ * simulated loadout has to be interchangeable with one from the live pack,
348
+ * or an agent's decision would depend on which read produced the object.
349
+ * Gated on the player domain, since what it answers is a question about the
350
+ * player. */
351
+ simulateLoadout: gateRead(
352
+ caps,
353
+ D.player,
354
+ (change: LoadoutChange): LoadoutSimulation | null =>
355
+ simulateLoadout(state, change, { viewDeps: deps }),
356
+ ),
503
357
  };
504
358
  }
@@ -48,8 +48,17 @@ import type { ObjectKind } from "../obj/types.js";
48
48
  * an agent could not see the map the player sees, and the invented table would
49
49
  * drift from the gamedata with nothing to notice. Purely additive - every
50
50
  * field is absent without the dep, exactly as `featCode` is without a resolver.
51
+ *
52
+ * 1.2.0 (2026-08-21): `AgentView.simulateLoadout` and the `LoadoutChange` /
53
+ * `LoadoutSimulation` shapes it speaks in. Added because the view could describe
54
+ * the character it HAS and nothing else, so every decision of the form "would
55
+ * this item be better than the one I am wearing" had to be answered by summing
56
+ * the item's own bonuses - a second implementation of calc_bonuses, guaranteed to
57
+ * drift from the first. The accessor runs the engine's own derive over a
58
+ * hypothetical set of worn objects. Purely additive, and optional: a view that
59
+ * cannot derive omits it rather than answering approximately.
51
60
  */
52
- export const AGENT_API_VERSION = "1.1.0";
61
+ export const AGENT_API_VERSION = "1.2.0";
53
62
 
54
63
  /**
55
64
  * A command an agent emits - identical to the engine's PlayerCommand (codes 1:1
@@ -231,7 +240,21 @@ export interface CellView {
231
240
  objectCount: number;
232
241
  /** SQUARE_GLOW: the square is self-illuminating. */
233
242
  glow: boolean;
234
- /** A live trap pile occupies this square. */
243
+ /**
244
+ * `square_isdisarmabletrap` (cave-square.c:832): the square holds a VISIBLE
245
+ * player trap that is not already disabled - the same predicate the `disarm`
246
+ * command tests before it will spend a turn, and the same one the trap layer
247
+ * draws from.
248
+ *
249
+ * NOT "any trap record exists", which is what this reported until 0.25.1. The
250
+ * trap list is also where a closed door's lock lives (`square_set_door_lock`
251
+ * stores a "door lock" trap, flagged LOCK | INVISIBLE) and where a glyph of
252
+ * warding, a web and a decoy live. None of those is a trap a player can see or
253
+ * disarm, so reporting them here contradicted this view's own rule that an
254
+ * invisible trap is not in the view (see `trapGlyph`) - and it put a locked
255
+ * door in front of an agent as something to disarm, which `disarm` then
256
+ * refuses for free.
257
+ */
235
258
  trap: boolean;
236
259
  /** Namespaced terrain-feature id, when a ContentIdResolver dep is supplied
237
260
  * and the feature index is bound (never present for an unset sentinel). */
@@ -372,6 +395,130 @@ export interface TargetView {
372
395
  * The perceive facade: a read-only view of the world. Every accessor returns
373
396
  * fresh plain data (no live engine references). Capability: state:*.read.
374
397
  */
398
+ /* ------------------------------------------------------------------ *
399
+ * PERCEIVE - a loadout the player is NOT wearing.
400
+ * ------------------------------------------------------------------ */
401
+
402
+ /**
403
+ * Where one item in a hypothetical loadout comes from.
404
+ *
405
+ * Three arms because the three questions have three different answers to "which
406
+ * object is this". Something the character already has is a gear handle, which
407
+ * is what every other item verb takes. A ware in a shop is NOT in the gear at
408
+ * all, so it has no handle - `StoreItemView.index` inside `view.stores()[n]` is
409
+ * its only address, and pricing a purchase is one of the two decisions this
410
+ * whole capability exists for. The third arm is for a caller INSIDE the engine
411
+ * that is holding the object itself (a floor pile, a freshly rolled drop, a
412
+ * character-sheet comparison); an agent driving the frozen view has no
413
+ * GameObject and will never use it.
414
+ */
415
+ export type LoadoutItemRef =
416
+ /** Something already in the gear, worn or packed. */
417
+ | { readonly from: "gear"; readonly handle: number }
418
+ /** Store stock: `store` indexes view.stores(), `index` the ware in it. */
419
+ | { readonly from: "store"; readonly store: number; readonly index: number }
420
+ /** An object in hand (engine-internal callers only). */
421
+ | { readonly from: "object"; readonly object: import("../obj/object.js").GameObject };
422
+
423
+ /**
424
+ * A hypothetical change to what the character carries and wears.
425
+ *
426
+ * Every field is optional and they compose, applied in the order
427
+ * release -> remove -> wield -> carry, so "sell the ring I am wearing and put on
428
+ * this one instead" is one change rather than two simulations. An empty change
429
+ * derives the loadout the character is already in, which is what makes `before`
430
+ * and `after` comparable rather than two different derives.
431
+ */
432
+ export interface LoadoutChange {
433
+ /**
434
+ * Wear or wield these, each routed to the body slot wield_slot picks for its
435
+ * tval (a ring goes to the first EMPTY ring slot, else the first ring slot).
436
+ * Whatever was in the slot moves to the pack. An item from `store` or
437
+ * `object` is being ACQUIRED, so its weight joins the carried total.
438
+ */
439
+ readonly wield?: readonly LoadoutItemRef[];
440
+ /**
441
+ * Take these into the pack without wearing them - a purchase of something the
442
+ * character will carry rather than wield. `number` defaults to the whole
443
+ * stack the reference names.
444
+ */
445
+ readonly carry?: readonly {
446
+ readonly item: LoadoutItemRef;
447
+ readonly number?: number;
448
+ }[];
449
+ /**
450
+ * Empty these body slots. What is worn there goes into the pack, so the
451
+ * carried weight does not change - this is taking something off, not
452
+ * getting rid of it.
453
+ */
454
+ readonly remove?: readonly number[];
455
+ /**
456
+ * Give these up entirely: a sale, or a drop. `number` defaults to the whole
457
+ * stack. A handle that names WORN gear empties its slot on the way out, so a
458
+ * caller pricing "sell the amulet I have on" does not have to remove it first.
459
+ */
460
+ readonly release?: readonly {
461
+ readonly handle: number;
462
+ readonly number?: number;
463
+ }[];
464
+ }
465
+
466
+ /** Where one wielded item landed, and what it pushed out of that slot. */
467
+ export interface LoadoutPlacement {
468
+ /** The body slot index, as `equipment()` is indexed. */
469
+ readonly slot: number;
470
+ /** The item now in the slot. */
471
+ readonly worn: ItemView;
472
+ /** What was in the slot before, or null when it was empty. */
473
+ readonly displaced: ItemView | null;
474
+ }
475
+
476
+ /** One complete loadout: what is worn and carried, and what it derives to. */
477
+ export interface LoadoutView {
478
+ /**
479
+ * The player as `player()` would report them wearing this loadout. Every
480
+ * derived field (speed, ac, toHit, toDam, blows, shots, light, seeInfra,
481
+ * objectFlags, maxHp, maxSp) is this loadout's; the rest of the character is
482
+ * unchanged.
483
+ */
484
+ readonly player: PlayerView;
485
+ /** Worn equipment by body slot; null for an empty slot. */
486
+ readonly equipment: readonly (ItemView | null)[];
487
+ /** The pack this loadout leaves behind, in pack order. */
488
+ readonly inventory: readonly ItemView[];
489
+ /**
490
+ * The WHOLE derived surface for this loadout - every field of upstream's
491
+ * player_state, plus max hitpoints, max mana, armour encumbrance and the
492
+ * carried weight. This is the deep answer; `player` is the frozen-contract
493
+ * projection of the same derive.
494
+ */
495
+ readonly stats: import("../player/loadout.js").DerivedStatsView;
496
+ }
497
+
498
+ /**
499
+ * The answer to "what would this loadout do for me": the character as it stands,
500
+ * the character with the change applied, and the difference between them.
501
+ *
502
+ * A BEFORE/AFTER/DELTA TRIPLE rather than a score, and deliberately. An
503
+ * autoplayer reduces it to one number and a player wants to see which resist was
504
+ * traded for which; a scalar can serve the first and never the second, and the
505
+ * two must not be allowed to disagree about the underlying derive.
506
+ */
507
+ export interface LoadoutSimulation {
508
+ readonly before: LoadoutView;
509
+ readonly after: LoadoutView;
510
+ readonly delta: import("../player/loadout.js").DerivedStatsDelta;
511
+ /** Where each wielded item landed (empty when the change wields nothing). */
512
+ readonly placements: readonly LoadoutPlacement[];
513
+ /**
514
+ * References the change named that resolved to no object: a stale handle, a
515
+ * store index past the end of the stock, a shop number with no shop. They are
516
+ * SKIPPED rather than thrown for, and reported here, because a decision ladder
517
+ * evaluating a hundred candidates must not die on one stale handle.
518
+ */
519
+ readonly unresolved: readonly LoadoutItemRef[];
520
+ }
521
+
375
522
  export interface AgentView {
376
523
  readonly apiVersion: string;
377
524
  /** The int32 game-turn counter. */
@@ -398,6 +545,23 @@ export interface AgentView {
398
545
  spellbooks(): SpellbookView[];
399
546
  /** A plain clone of the bound game constants (z_info). */
400
547
  constants(): GameConstants;
548
+ /**
549
+ * What the character would derive to wearing a DIFFERENT loadout: the same
550
+ * calc_bonuses the engine runs for the real one, over a hypothetical set of
551
+ * worn objects, with nothing in the live game touched.
552
+ *
553
+ * This is the read that had no answer at all. Every other accessor here
554
+ * reports the character as it is, so an agent deciding whether to wear, buy or
555
+ * sell something had to sum the item's own bonuses itself - which is a second
556
+ * implementation of calc_bonuses, and one that would drift from the first.
557
+ *
558
+ * OPTIONAL, and null-returning, for the honest reason: the derive needs the
559
+ * session's own calc_bonuses options (the bound timed table, the curse
560
+ * registry), so a view over a worldless harness cannot answer and says so
561
+ * rather than deriving a thinner state that would look like an answer.
562
+ * Capability: `state:player.read`.
563
+ */
564
+ simulateLoadout?(change: LoadoutChange): LoadoutSimulation | null;
401
565
  }
402
566
 
403
567
  /**
@@ -407,6 +407,24 @@ export interface GameState {
407
407
  * "nothing is known", exactly as an absent playerState does.
408
408
  */
409
409
  knownPlayerState?: import("../player/calcs.js").PlayerState;
410
+ /**
411
+ * calc_bonuses for a loadout the player is NOT wearing: the derive behind
412
+ * "what would this item do for me". Installed by wireGame, which is the only
413
+ * place holding the whole options bag the live refresh uses (the bound timed
414
+ * table and the curse registry), so a caller cannot end up measuring a
415
+ * hypothetical loadout with a thinner derive than the real one.
416
+ *
417
+ * `equipment` is indexed by body slot, exactly as CalcBonusesOptions.equipment
418
+ * is; `totalWeight` is the burden that loadout would carry (omit to keep the
419
+ * player's current one). Runs with update=false, so it has no side effects.
420
+ *
421
+ * Absent in the worldless harness, and simulateLoadout (agent/loadout.ts)
422
+ * answers null rather than deriving a thinner state of its own.
423
+ */
424
+ derivedFor?: (
425
+ equipment: readonly (import("../obj/object.js").GameObject | null)[],
426
+ totalWeight?: number,
427
+ ) => import("../player/calcs.js").PlayerState;
410
428
  /**
411
429
  * player->upkeep->health_who (health_track reduced to the tracked
412
430
  * monster; the health-bar redraw rides presentation, #25).
package/src/index.ts CHANGED
@@ -84,6 +84,7 @@ export * from "./player/options-file.js";
84
84
  export * from "./player/bind.js";
85
85
  export * from "./player/player.js";
86
86
  export * from "./player/calcs.js";
87
+ export * from "./player/loadout.js";
87
88
  export * from "./player/timed.js";
88
89
  export * from "./player/take-hit.js";
89
90
  export * from "./player/spell.js";
@@ -374,6 +374,11 @@ export interface TileFillPack {
374
374
  * atlas has no spare cell to put a variant in, and even a loose pack cannot
375
375
  * recolour a donor it does not own the asset for - so a filler that wants a
376
376
  * plain copy instead has to say so.
377
+ *
378
+ * `transform` is the second, on the same terms: give me a tile that draws
379
+ * `donor`'s asset mirrored, or with its colours replaced from a ramp I hand
380
+ * over, or both. Which donor, which way round and which colours are all the
381
+ * caller's - see TileTransform.
377
382
  */
378
383
  export interface TileFill {
379
384
  /** The pack being filled. */
@@ -388,8 +393,96 @@ export interface TileFill {
388
393
  fillObject(kidx: number, tile: TileAtlas): boolean;
389
394
  /** A tile drawing `donor`'s asset with its hue rotated, or null if impossible. */
390
395
  derive(donor: TileAtlas, hue: number): TileAtlas | null;
396
+ /**
397
+ * A tile drawing `donor`'s asset mirrored and/or palette-swapped, or null if
398
+ * impossible - the same three refusals `derive` has, for the same reasons.
399
+ */
400
+ transform(donor: TileAtlas, spec: TileTransform): TileAtlas | null;
401
+ }
402
+
403
+ /** One replacement colour in a palette remap: red, green, blue, each 0-255. */
404
+ export type TileRampColour = readonly [number, number, number];
405
+
406
+ /**
407
+ * How a transformed tile differs from the donor whose picture it draws.
408
+ *
409
+ * A PALETTE REMAP RATHER THAN A HUE ROTATION, and the two are not variants of
410
+ * one idea. `derive`'s rotation asks "the same picture, turned": it keeps the
411
+ * donor's own colours and moves them around the wheel, which is why it is a
412
+ * no-op on grey. A ramp remap asks for a DIFFERENT palette: every pixel is
413
+ * indexed into the ramp by its luminance and replaced by the colour that index
414
+ * names, so the result is in the caller's colours whatever the donor's were,
415
+ * grey donors included. Alpha is carried through untouched, so the silhouette
416
+ * is the donor's exactly.
417
+ *
418
+ * Fewer ramp entries read as flatter and more stylised; more preserve the
419
+ * donor's shading. Two is a hard duotone. The caller picks, because how
420
+ * stylised somebody's art should look is taste and the engine has none.
421
+ *
422
+ * A ramp of nothing (or of one colour) is not a palette, so it is treated as
423
+ * "no colour change" and only `mirror` applies. A transform that asks for
424
+ * neither is refused, the same way a rotation of nothing is: it would allocate
425
+ * a tile indistinguishable from its donor.
426
+ */
427
+ export interface TileTransform {
428
+ /** Mirror the picture horizontally. */
429
+ readonly mirror: boolean;
430
+ /**
431
+ * Replacement colours, DARKEST FIRST.
432
+ *
433
+ * There is a cap on how many, and it lives with the DOOR rather than here
434
+ * (`TILE_RAMP_MAX`, the front end's tile-registry) because the reason for it is
435
+ * the front end's: it caches one image per distinct spec, so what bounds a ramp
436
+ * is what bounds that cache. Core describes the shape; the front end that pays
437
+ * for it sets the limit.
438
+ */
439
+ readonly ramp: readonly TileRampColour[];
391
440
  }
392
441
 
442
+ /**
443
+ * What a player-tile provider is told about the character it is drawing.
444
+ *
445
+ * DELIBERATELY NOT THE PLAYER RECORD. A provider decides what picture to show,
446
+ * and the five facts below are what any such decision can be made from; handing
447
+ * over the live `Player` would make every field of it part of this contract and
448
+ * let a render-time hook mutate the character. Names rather than indices,
449
+ * because a mod's rule is written against "Druid", not against cidx 2, and an
450
+ * index moves when a content mod inserts a class.
451
+ */
452
+ export interface PlayerTileView {
453
+ /**
454
+ * The current shape's name ("fox", "werewolf", ...), or null in the normal
455
+ * shape. Exactly player_is_shapechanged's question, answered with which shape.
456
+ */
457
+ readonly shape: string | null;
458
+ /** player->lev, 1 to PY_MAX_LEVEL. */
459
+ readonly level: number;
460
+ /** The character's class name (player.cls.name). */
461
+ readonly cls: string;
462
+ /** The character's race name (player.race.name). */
463
+ readonly race: string;
464
+ }
465
+
466
+ /**
467
+ * A mod's player-tile provider: which tile the PLAYER's own cell draws.
468
+ *
469
+ * WHY THIS IS NOT A FILL. The player is race 0 in the monster tile table
470
+ * (grid_data_as_text's is_player branch reads that slot for both the colour and
471
+ * the character), and every tile set the game ships assigns it - so there is no
472
+ * blank for `fillMonster` to write into, and there should not be: the pack's
473
+ * player picture is the pack author's work. This asks a different question,
474
+ * once per frame rather than once per map build: given who the character is
475
+ * right now, is there a tile that fits better than the standing one? Null means
476
+ * no, and the pack's own player tile is drawn, which is what happens with no
477
+ * provider installed at all.
478
+ *
479
+ * It runs inside the render path, so it must be a lookup and not a computation:
480
+ * allocate whatever tiles the answers need during the fill (where `transform`
481
+ * lives) and read the table here. A provider that throws loses its answer for
482
+ * that frame and nothing else.
483
+ */
484
+ export type PlayerTileProvider = (view: PlayerTileView) => TileAtlas | null;
485
+
393
486
  /**
394
487
  * A mod's tile filler: supply tiles for content this pack does not draw.
395
488
  *
@@ -407,6 +500,7 @@ export type TileFiller = (fill: TileFill) => void;
407
500
  /** Structural target implemented by the web front end, not by headless core. */
408
501
  export interface TileRegistryTarget {
409
502
  register(filler: TileFiller, owner?: string): void;
503
+ player(provider: PlayerTileProvider, owner?: string): void;
410
504
  }
411
505
 
412
506
  /** The effect-override facade (gated by registry:effect). */
@@ -573,6 +667,13 @@ export interface MenuFacade {
573
667
  export interface TilesFacade {
574
668
  /** Install (or replace) this mod's tile filler. */
575
669
  register(filler: TileFiller): void;
670
+ /**
671
+ * Install (or replace) this mod's player-tile provider. One per mod for the
672
+ * same reason as the filler, and first non-null in load order wins - so a
673
+ * provider that answers null for everything it has no opinion about leaves
674
+ * the next mod's answer, and the pack's own tile, both reachable.
675
+ */
676
+ player(provider: PlayerTileProvider): void;
576
677
  }
577
678
 
578
679
  /** The monster-AI facade (gated by registry:monster). */
@@ -1490,6 +1591,13 @@ export function createModRegistryHost(
1490
1591
  }
1491
1592
  requireTarget(targets.tiles, "tiles").register(filler);
1492
1593
  },
1594
+ player(provider): void {
1595
+ requireCap(capabilities, "tiles");
1596
+ if (typeof provider !== "function") {
1597
+ throw new Error("mod registry: a player-tile provider must be a function");
1598
+ }
1599
+ requireTarget(targets.tiles, "tiles").player(provider);
1600
+ },
1493
1601
  },
1494
1602
  };
1495
1603
  }
@@ -644,6 +644,15 @@ export interface CalcBonusesOptions {
644
644
  * exactly as upstream. Absent (the live refresh path), no shift is applied.
645
645
  */
646
646
  statIndBoost?: { str: number; dex: number };
647
+ /**
648
+ * p->upkeep->total_weight (the "Analyze weight" block, 2222-2230), in tenth
649
+ * pounds. Defaults to the player's own carried weight, which is the live
650
+ * answer. A HYPOTHETICAL loadout supplies the weight it would carry instead:
651
+ * wearing something already in the pack changes nothing, but buying a mail
652
+ * shirt adds its weight and can cost speed, and a derive that read the current
653
+ * burden would miss exactly that.
654
+ */
655
+ totalWeight?: number;
647
656
  /**
648
657
  * p->depth (calc_light town-daytime early-out, 1607). Defaults to undefined,
649
658
  * treated as a nonzero depth so the town branch stays dormant.
@@ -712,6 +721,18 @@ function calcLight(
712
721
  calc_light sets p->upkeep->update |= (PU_UPDATE_VIEW | PU_MONSTERS) when
713
722
  the town light changes (1608-1611), and the port has no dirty-flag pipeline
714
723
  to set: the front end recomputes and repaints after every action. */
724
+ /* GATED ON `update`, exactly as upstream gates it (player-calcs.c:1607 -
725
+ * `if (!p->depth && is_daytime() && update)`). So a SHADOW derive computes
726
+ * cur_light from the equipment even in a lit town, and the live derive reports
727
+ * zero. That asymmetry is upstream's, and it is a wart the port keeps.
728
+ *
729
+ * WHAT IT COSTS, because it was measured and is worth knowing: a consumer that
730
+ * compares a shadow derive's score against the LIVE derive's score is comparing
731
+ * two different answers, and in a daytime town the gap is a whole torch's light
732
+ * radius. `simulateLoadout` derives both of its sides through this same shadow
733
+ * path, so its own `before` / `after` / `delta` are consistent; the baseline to
734
+ * compare an `after` against is that `before`, never the live score. See
735
+ * agent/loadout.ts. */
715
736
  const depth = options.depth;
716
737
  const update = options.update ?? true;
717
738
  if (depth === 0 && (options.isDaytime ?? false) && update) {
@@ -1289,7 +1310,7 @@ export function calcBonuses(
1289
1310
  }
1290
1311
 
1291
1312
  /* Analyze weight (2222-2230). */
1292
- const j = player.upkeep.totalWeight;
1313
+ const j = options.totalWeight ?? player.upkeep.totalWeight;
1293
1314
  const limit = weightLimit(state);
1294
1315
  if (j > Math.trunc(limit / 2)) {
1295
1316
  state.speed -=