@jgengine/core 0.12.0 → 0.13.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +25 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +2 -1
  29. package/dist/runtime/context/scene.js +39 -1
  30. package/dist/runtime/gameContext.js +50 -4
  31. package/dist/runtime/gameContextTypes.d.ts +37 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/colliders.d.ts +21 -0
  36. package/dist/scene/colliders.js +62 -0
  37. package/dist/scene/entityStats.js +9 -1
  38. package/dist/stats/statModifiers.d.ts +16 -0
  39. package/dist/stats/statModifiers.js +30 -1
  40. package/dist/stats/statPool.js +5 -2
  41. package/dist/ui/panelModel.d.ts +120 -0
  42. package/dist/ui/panelModel.js +168 -0
  43. package/dist/ui.d.ts +1 -0
  44. package/dist/ui.js +1 -0
  45. package/dist/world/buildingLots.d.ts +84 -0
  46. package/dist/world/buildingLots.js +119 -0
  47. package/dist/world/buildings.d.ts +6 -0
  48. package/dist/world/cityGenerator.d.ts +41 -0
  49. package/dist/world/cityGenerator.js +58 -0
  50. package/dist/world/cityKind.d.ts +2 -2
  51. package/dist/world/cityKind.js +4 -4
  52. package/dist/world/environmentSummary.js +57 -1
  53. package/dist/world/features.d.ts +35 -2
  54. package/dist/world/features.js +5 -2
  55. package/dist/world/roads.d.ts +19 -2
  56. package/dist/world/roads.js +60 -3
  57. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  58. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  59. package/dist/world/streets.d.ts +74 -1
  60. package/dist/world/streets.js +218 -0
  61. package/dist/world/terrain.d.ts +20 -1
  62. package/dist/world/terrain.js +41 -2
  63. package/package.json +1 -1
  64. package/skills/jgengine/SKILL.md +1 -0
  65. package/skills/jgengine/api.md +13 -5
  66. package/skills/jgengine-combat/SKILL.md +15 -1
  67. package/skills/jgengine-combat/api.md +39 -2
  68. package/skills/jgengine-combat/capabilities.md +17 -1
  69. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  70. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  71. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  72. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  73. package/skills/jgengine-combat/reference.md +22 -3
  74. package/skills/jgengine-gameplay/SKILL.md +8 -0
  75. package/skills/jgengine-gameplay/api.md +35 -5
  76. package/skills/jgengine-gameplay/capabilities.md +18 -0
  77. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  78. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  79. package/skills/jgengine-world/api.md +52 -24
  80. package/skills/jgengine-world/capabilities.md +8 -4
  81. package/skills/jgengine-world/reference.md +1 -1
@@ -6,6 +6,7 @@ import { createGameFeed } from "../game/feed.js";
6
6
  import { setGamePhase } from "../game/gamePhase.js";
7
7
  import { createLootRegistry, grantDrops } from "../game/lootTable.js";
8
8
  import { createSocial } from "../game/social.js";
9
+ import { splitStack } from "../inventory/inventoryModel.js";
9
10
  import { createItemUse } from "../item/use.js";
10
11
  import { createWeaponStats } from "../item/weapon.js";
11
12
  import {} from "../scene/entityStats.js";
@@ -55,7 +56,7 @@ export function createGameContext(options) {
55
56
  ...(occluder !== undefined ? { occluder } : {}),
56
57
  ...(options.models !== undefined ? { models: options.models } : {}),
57
58
  });
58
- const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
60
  // --- Shared game services (weapon, feed, features bag, commands, item use) ---
60
61
  const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
61
62
  const feed = createGameFeed(definition.feed);
@@ -114,6 +115,45 @@ export function createGameContext(options) {
114
115
  });
115
116
  const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
116
117
  scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
118
+ // Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
119
+ // command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
120
+ // user's own inventory, so it respects `privatePerUser` replication.
121
+ const knownInventory = (inventoryId) => layouts[inventoryId] !== undefined;
122
+ const slotInRange = (inventoryId, slot) => typeof slot === "number" && Number.isInteger(slot) && slot >= 0 && slot < layouts[inventoryId].slots;
123
+ commandRegistry.define("inventory.move", {
124
+ validate(_state, input) {
125
+ if (!knownInventory(input.inventoryId))
126
+ return { reason: `unknown inventory "${input.inventoryId}"` };
127
+ if (!slotInRange(input.inventoryId, input.from) || !slotInRange(input.inventoryId, input.to)) {
128
+ return { reason: "invalid-slot" };
129
+ }
130
+ return null;
131
+ },
132
+ apply(_state, input) {
133
+ const inventory = inventoryFor(activeUserId());
134
+ // A rejected move (empty source, wrong-kind, no-space) is a harmless no-op: the item bounces back.
135
+ inventory.move(input.inventoryId, input.from, input.inventoryId, input.to);
136
+ },
137
+ });
138
+ commandRegistry.define("inventory.split", {
139
+ validate(_state, input) {
140
+ if (!knownInventory(input.inventoryId))
141
+ return { reason: `unknown inventory "${input.inventoryId}"` };
142
+ if (!slotInRange(input.inventoryId, input.slot))
143
+ return { reason: "invalid-slot" };
144
+ if (input.toSlot !== undefined && !slotInRange(input.inventoryId, input.toSlot)) {
145
+ return { reason: "invalid-slot" };
146
+ }
147
+ return null;
148
+ },
149
+ apply(_state, input) {
150
+ const inventory = inventoryFor(activeUserId());
151
+ const result = splitStack(inventory.state(input.inventoryId), input.slot, input.amount, input.toSlot);
152
+ if (result.status === "rejected")
153
+ return;
154
+ inventory.replaceState(input.inventoryId, result.state);
155
+ },
156
+ });
117
157
  const loot = {
118
158
  register: lootRegistry.register,
119
159
  has: lootRegistry.has,
@@ -328,6 +368,7 @@ export function createGameContext(options) {
328
368
  signal.notify();
329
369
  },
330
370
  collidersOf: entityCollidersOf,
371
+ reportBounds: reportEntityBounds,
331
372
  visualScaleOf: entityVisualScaleOf,
332
373
  form: forms,
333
374
  paint: paintLayer,
@@ -472,16 +513,21 @@ function saveKey(name) {
472
513
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
473
514
  return `jgengine:save:${slug === "" ? "game" : slug}`;
474
515
  }
475
- /** Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise `defineGame({ persist })` auto-wires a localStorage save for offline/single-player worlds only. */
516
+ /**
517
+ * Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise an offline /
518
+ * single-player world auto-wires a `localStorage` whole-world save **by default** — a game gets `ctx.game.save`
519
+ * and autosave without touching `persist`. Only an explicit `persist: false` opts out; a host-authoritative
520
+ * multiplayer world never persists to `localStorage` (its host owns persistence) regardless of `persist`.
521
+ */
476
522
  function resolveSaveOptions(definition, options) {
477
523
  if (options.save !== undefined)
478
524
  return options.save;
479
525
  const persist = definition.persist;
480
- if (persist === undefined || persist === false)
526
+ if (persist === false)
481
527
  return undefined;
482
528
  if (!isOffline(definition.multiplayer))
483
529
  return undefined;
484
- const config = persist === true ? {} : persist;
530
+ const config = persist === undefined || persist === true ? {} : persist;
485
531
  return {
486
532
  backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
487
533
  mode: config.mode,
@@ -2,6 +2,7 @@ import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
2
2
  import type { EffectInput, EffectResult, EffectVia, ReceiveMap, SingleTargetEffectInput } from "../combat/effects.js";
3
3
  import type { ProjectileSystem } from "../combat/projectiles.js";
4
4
  import type { HitReaction, HitReactionConfig, ImpactPresetName } from "../combat/hitReaction.js";
5
+ import type { VfxPresetName } from "../combat/vfxPresets.js";
5
6
  import type { TelegraphShape } from "../combat/telegraph.js";
6
7
  import type { CommandDefinition, CommandResult } from "../commands/commandRegistry.js";
7
8
  import type { ChargeOptions as WalletChargeOptions } from "../economy/wallet.js";
@@ -33,7 +34,7 @@ import type { PaintLayer } from "../scene/paintLayer.js";
33
34
  import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
34
35
  import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
35
36
  import type { Forms } from "../scene/form.js";
36
- import type { EntityColliderSet, ModelBodySource } from "../scene/colliders.js";
37
+ import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
37
38
  import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
38
39
  import type { ObjectStore } from "../scene/objectStore.js";
39
40
  import type { Roster } from "../scene/roster.js";
@@ -135,6 +136,15 @@ export interface SceneObjectContext extends ObjectStore {
135
136
  raycastAll(input: ObjectRaycastInput): readonly ObjectRaycastHit[];
136
137
  setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
137
138
  collidersOf(instanceId: string): EntityColliderSet | null;
139
+ /**
140
+ * Report the entity-local AABB the renderer actually mounted for `catalogId` — stored per catalog
141
+ * id as a blocking physical body and consulted after authored `colliders` and index-`dims` fitted
142
+ * models, before the visual-scale fallback. The shell reports custom `renderObject` content and
143
+ * dims-less models automatically, so a placed prop's body wraps its real rendered shape instead of
144
+ * a unit-cube guess. Pass `null` to clear. Returns `false` when the bounds are degenerate.
145
+ * @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
146
+ */
147
+ reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
138
148
  /**
139
149
  * Per-instance selection/highlight state for placed objects — the reactive counterpart to
140
150
  * `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
@@ -156,10 +166,22 @@ export interface FloatTextInput {
156
166
  crit?: boolean;
157
167
  scale?: number;
158
168
  }
159
- /** Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`. */
169
+ /**
170
+ * Request a transient spell/ability VFX burst. The easy path is a named `preset` —
171
+ * `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color
172
+ * or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"`
173
+ * and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id
174
+ * (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`,
175
+ * `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color`
176
+ * yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
177
+ */
160
178
  export interface VfxInput {
161
- kind: VfxKind;
162
- color: number;
179
+ /** A named visual flavor from {@link vfxPresets}. Fills in `kind`/`color`/defaults; explicit fields below still win. */
180
+ preset?: VfxPresetName | (string & {});
181
+ /** Visual archetype. Optional when `preset` is given; required otherwise. */
182
+ kind?: VfxKind;
183
+ /** `0xRRGGBB` tint. Optional when `preset` is given; required otherwise. */
184
+ color?: number;
163
185
  from?: string | readonly [number, number, number];
164
186
  to?: string | readonly [number, number, number];
165
187
  radius?: number;
@@ -242,6 +264,17 @@ export interface SceneEntityContext {
242
264
  invalidateSpatial: SpatialApi["invalidate"];
243
265
  setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
244
266
  collidersOf(instanceId: string): EntityColliderSet | null;
267
+ /**
268
+ * Report the entity-local AABB the renderer actually mounted for `kind` — stored per kind as a
269
+ * tight damage hitbox and consulted after authored `colliders` and index-`dims` fitted models,
270
+ * before the scale/humanoid-default fallbacks. The shell reports custom `renderEntity` content and
271
+ * dims-less models automatically, so hitboxes wrap the rendered mesh instead of the fixed
272
+ * 0.7×1.8×0.7 box. Runtime-measured on the client; authoritative hosts that never render should
273
+ * author `colliders` or model `dims` instead. Pass `null` to clear. Returns `false` when the
274
+ * bounds are degenerate.
275
+ * @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
276
+ */
277
+ reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
245
278
  /** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
246
279
  visualScaleOf(instanceId: string): number;
247
280
  form: Forms;
@@ -36,6 +36,17 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
36
36
  /** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */
37
37
  models?: GameContextModels;
38
38
  }
39
+ /** Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`. */
40
+ export interface HeadlessCommandInterface {
41
+ /** Invoke a registered command/intent by name against the live context. Throws on `unknown-command` or a validation rejection so a headless UI-flow test fails loudly instead of silently no-op'ing. */
42
+ invoke(name: string, input?: unknown): void;
43
+ /** Non-throwing {@link invoke} returning the raw command result (inspect `status`). */
44
+ tryInvoke(name: string, input?: unknown): ReturnType<GameContext["game"]["commands"]["run"]>;
45
+ /** Registered command names. */
46
+ names(): string[];
47
+ /** Is a command registered? */
48
+ has(name: string): boolean;
49
+ }
39
50
  /**
40
51
  * A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init
41
52
  * hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole
@@ -58,5 +69,7 @@ export interface HeadlessRunner {
58
69
  step(dtSeconds: number, input?: HeadlessInput): number;
59
70
  /** Publish input without advancing — for pre-seeding the held set before the first {@link step}. */
60
71
  publishInput(input: HeadlessInput): void;
72
+ /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
73
+ readonly ui: HeadlessCommandInterface;
61
74
  }
62
75
  export declare function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner;
@@ -29,6 +29,20 @@ export function createHeadlessRunner(options) {
29
29
  input: ctx.input,
30
30
  userId: player.userId,
31
31
  publishInput,
32
+ ui: {
33
+ invoke(name, input) {
34
+ const result = ctx.game.commands.run(name, input);
35
+ if (result.status === "unknown-command") {
36
+ throw new Error(`headless ui.invoke: unknown command "${name}" — define it via ctx.game.commands.define`);
37
+ }
38
+ if (result.status === "rejected") {
39
+ throw new Error(`headless ui.invoke: command "${name}" rejected — ${result.reason}`);
40
+ }
41
+ },
42
+ tryInvoke: (name, input) => ctx.game.commands.run(name, input),
43
+ names: () => ctx.game.commands.names(),
44
+ has: (name) => ctx.game.commands.has(name),
45
+ },
32
46
  step(dtSeconds, input) {
33
47
  if (input !== undefined)
34
48
  publishInput(input);
@@ -22,6 +22,8 @@ export interface ModelAssetRef {
22
22
  collisionMesh?: CollisionMeshData;
23
23
  /** Authored asset-space metadata — canonical facing (degrees), source-unit scale, footprint, anchor, bounds, and rotation policy. The upstream owner of placement corrections; see {@link "scene/assetSpace".AssetSpace}. */
24
24
  space?: AssetSpace;
25
+ /** Animation clip names read from the GLB at asset reindex. Lets the shell auto-animate rigged catalog assets (semantic clip roles → default idle/walk/run + one-shots) with no game-side clip strings. */
26
+ clips?: readonly string[];
25
27
  }
26
28
  /**
27
29
  * A catalog entry backed by a registered {@link registerAssetGenerator} instead of a GLB URL — a
@@ -107,6 +107,27 @@ export declare function fittedEntityColliders(model: ModelBodySource): EntityCol
107
107
  * @internal
108
108
  */
109
109
  export declare function fittedObjectColliders(model: ModelBodySource): EntityColliderSet | null;
110
+ /**
111
+ * Entity-local AABB of what the renderer actually mounted for a kind — the runtime counterpart to
112
+ * index-measured `dims`. Reported through `ctx.scene.entity.reportBounds` /
113
+ * `ctx.scene.object.reportBounds` (the shell does this automatically for custom
114
+ * `renderEntity`/`renderObject` content and models without index dims) so collider resolution can
115
+ * wrap the real rendered shape instead of a fixed-size guess.
116
+ */
117
+ export interface MeasuredBounds {
118
+ min: EntityPosition;
119
+ max: EntityPosition;
120
+ }
121
+ /** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
122
+ * entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
123
+ * @internal
124
+ */
125
+ export declare function measuredEntityColliders(bounds: MeasuredBounds): EntityColliderSet | null;
126
+ /** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
127
+ * stores for an object catalog id. `null` when the bounds are degenerate.
128
+ * @internal
129
+ */
130
+ export declare function measuredObjectColliders(bounds: MeasuredBounds): EntityColliderSet | null;
110
131
  /** @internal */
111
132
  export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
112
133
  /** @internal */
@@ -188,6 +188,68 @@ export function fittedObjectColliders(model) {
188
188
  },
189
189
  };
190
190
  }
191
+ /** Thin measured axes (decals, blades, flat panels) keep at least this half-extent so the box stays hittable. */
192
+ const MIN_MEASURED_HALF_EXTENT = 0.01;
193
+ /** Tight AABB from measured bounds — `null` when inverted, non-finite, or a degenerate point. */
194
+ function measuredBox(bounds) {
195
+ const { min, max } = bounds;
196
+ let volume = 0;
197
+ for (let axis = 0; axis < 3; axis += 1) {
198
+ const lo = min[axis];
199
+ const hi = max[axis];
200
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi < lo)
201
+ return null;
202
+ volume = Math.max(volume, hi - lo);
203
+ }
204
+ if (!(volume > 0))
205
+ return null;
206
+ return {
207
+ halfExtents: [
208
+ Math.max((max[0] - min[0]) / 2, MIN_MEASURED_HALF_EXTENT),
209
+ Math.max((max[1] - min[1]) / 2, MIN_MEASURED_HALF_EXTENT),
210
+ Math.max((max[2] - min[2]) / 2, MIN_MEASURED_HALF_EXTENT),
211
+ ],
212
+ offset: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
213
+ };
214
+ }
215
+ /** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
216
+ * entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
217
+ * @internal
218
+ */
219
+ export function measuredEntityColliders(bounds) {
220
+ const box = measuredBox(bounds);
221
+ if (box === null)
222
+ return null;
223
+ return {
224
+ hitboxes: [
225
+ {
226
+ name: "body",
227
+ purpose: "damage",
228
+ shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
229
+ damageEligible: true,
230
+ blocks: false,
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ /** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
236
+ * stores for an object catalog id. `null` when the bounds are degenerate.
237
+ * @internal
238
+ */
239
+ export function measuredObjectColliders(bounds) {
240
+ const box = measuredBox(bounds);
241
+ if (box === null)
242
+ return null;
243
+ return {
244
+ body: {
245
+ name: "body",
246
+ purpose: "physical",
247
+ shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
248
+ damageEligible: false,
249
+ blocks: true,
250
+ },
251
+ };
252
+ }
191
253
  /** @internal */
192
254
  export function resolveColliders(set) {
193
255
  if (set === null || set === undefined)
@@ -19,6 +19,11 @@ export function applyPoolDelta(map, statId, amount) {
19
19
  }
20
20
  const change = changeStatPool(existing, amount);
21
21
  const stat = change.pool;
22
+ // No-op change: `changeStatPool` handed back the same pool reference, so keep
23
+ // the existing map too — callers can guard on reference identity.
24
+ if (stat === existing) {
25
+ return { status: "ok", map, stat, hitMin: change.hitMin, hitMax: change.hitMax };
26
+ }
22
27
  return {
23
28
  status: "ok",
24
29
  map: { ...map, [statId]: stat },
@@ -81,10 +86,13 @@ export function createEntityStatsApi(resolve) {
81
86
  const map = resolve(instanceId);
82
87
  if (map === undefined)
83
88
  return { reason: `unknown instance "${instanceId}"` };
89
+ const existing = map[statId];
84
90
  const result = applyPoolDelta(map, statId, amount);
85
91
  if (result.status === "rejected")
86
92
  return { reason: result.reason };
87
- map[statId] = result.stat;
93
+ // Skip the store write when nothing moved so reference guards stay stable.
94
+ if (result.stat !== existing)
95
+ map[statId] = result.stat;
88
96
  return null;
89
97
  },
90
98
  };
@@ -6,6 +6,17 @@ export type StatModifierSet<TStat extends string> = Partial<Record<TStat, StatMo
6
6
  export interface CreateStatsOptions {
7
7
  now?: () => number;
8
8
  }
9
+ /** One serializable modifier source in a {@link StatsSnapshot}. */
10
+ interface StatModifierSourceSnapshot<TStat extends string> {
11
+ id: string;
12
+ modifiers: StatModifierSet<TStat>;
13
+ expiresAtMs?: number;
14
+ }
15
+ /** Complete plain-data state for a {@link Stats} runtime. */
16
+ export interface StatsSnapshot<TStat extends string> {
17
+ base: Record<TStat, number>;
18
+ sources: StatModifierSourceSnapshot<TStat>[];
19
+ }
9
20
  export interface Stats<TStat extends string> {
10
21
  setBase(stat: TStat, value: number): void;
11
22
  getBase(stat: TStat): number;
@@ -17,6 +28,10 @@ export interface Stats<TStat extends string> {
17
28
  get(stat: TStat, nowMs?: number): number;
18
29
  pruneExpired(nowMs: number): string[];
19
30
  sources(): string[];
31
+ /** Return a detached JSON-serializable copy of base values and modifier sources. */
32
+ snapshot(): StatsSnapshot<TStat>;
33
+ /** Replace all runtime state from caller-owned decoded snapshot data. */
34
+ restore(snapshot: StatsSnapshot<TStat>): void;
20
35
  }
21
36
  /**
22
37
  * A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
@@ -24,3 +39,4 @@ export interface Stats<TStat extends string> {
24
39
  * @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
25
40
  */
26
41
  export declare function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat>;
42
+ export {};
@@ -1,10 +1,19 @@
1
+ function cloneModifierSet(modifiers) {
2
+ const copy = {};
3
+ for (const stat of Object.keys(modifiers)) {
4
+ const modifier = modifiers[stat];
5
+ if (modifier !== undefined)
6
+ copy[stat] = { ...modifier };
7
+ }
8
+ return copy;
9
+ }
1
10
  /**
2
11
  * A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
3
12
  *
4
13
  * @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
5
14
  */
6
15
  export function createStats(base, options) {
7
- const baseValues = { ...base };
16
+ let baseValues = { ...base };
8
17
  const sourceEntries = new Map();
9
18
  const clock = options?.now;
10
19
  function resolveNow(nowMs) {
@@ -75,5 +84,25 @@ export function createStats(base, options) {
75
84
  sources() {
76
85
  return Array.from(sourceEntries.keys());
77
86
  },
87
+ snapshot() {
88
+ return {
89
+ base: { ...baseValues },
90
+ sources: Array.from(sourceEntries, ([id, entry]) => ({
91
+ id,
92
+ modifiers: cloneModifierSet(entry.modifiers),
93
+ ...(entry.expiresAtMs === undefined ? {} : { expiresAtMs: entry.expiresAtMs }),
94
+ })),
95
+ };
96
+ },
97
+ restore(snapshot) {
98
+ baseValues = { ...snapshot.base };
99
+ sourceEntries.clear();
100
+ for (const source of snapshot.sources) {
101
+ sourceEntries.set(source.id, {
102
+ modifiers: cloneModifierSet(source.modifiers),
103
+ expiresAtMs: source.expiresAtMs,
104
+ });
105
+ }
106
+ },
78
107
  };
79
108
  }
@@ -33,10 +33,13 @@ export function patchStatPool(pool, patch) {
33
33
  */
34
34
  export function changeStatPool(pool, amount) {
35
35
  const next = patchStatPool(pool, { current: pool.current + amount });
36
+ const applied = next.current - pool.current;
36
37
  return {
37
38
  previous: pool,
38
- pool: next,
39
- applied: next.current - pool.current,
39
+ // Preserve the input reference on a no-op change (regen at max, damage at 0,
40
+ // clamp to a bound) so reference-equality guards in HUD hooks don't churn.
41
+ pool: applied === 0 ? pool : next,
42
+ applied,
40
43
  hitMin: next.current === next.min,
41
44
  hitMax: next.current === next.max,
42
45
  };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Headless, serializable model for a set of player-toggled windows/panels — the data layer behind a
3
+ * WoW-style UI where B opens the bag, C the character sheet, and ESC closes the topmost window. It
4
+ * carries *which panels are open*, their focus/z stacking order, per-panel position overrides, and
5
+ * exclusive-group membership, with zero rendering opinion. The React chrome in
6
+ * `@jgengine/react/panels` consumes this; a game keeps layout, skin, keybinds, and window content
7
+ * caller-owned. Pure and immutable: every reducer returns a new state and never mutates its input.
8
+ */
9
+ /** A pixel position override for a panel (top-left, relative to the host surface). */
10
+ export interface PanelPosition {
11
+ x: number;
12
+ y: number;
13
+ }
14
+ /**
15
+ * A caller-authored panel/window declaration — the DATA input to the model. Only `id` is required so
16
+ * a bag, a character sheet, and a one-off dialog all share the shape.
17
+ */
18
+ export interface PanelDef {
19
+ id: string;
20
+ /** Human title shown in the window's title bar; falls back to `id`. */
21
+ title?: string;
22
+ /** Keybind that opens/toggles the panel — a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`), matched case-insensitively by {@link panelByHotkey}. */
23
+ hotkey?: string;
24
+ /** Open on {@link createPanelState} (default false). */
25
+ initial?: boolean;
26
+ /** Exclusive group: opening one panel in a group closes the others in it (e.g. only one center window). */
27
+ group?: string;
28
+ /** Whether the player can close it (title-bar button / ESC). Default true; `false` pins it open. */
29
+ closable?: boolean;
30
+ }
31
+ /** @internal Serializable per-panel metadata lifted from {@link PanelDef} so reducers stay `(state, id)` pure. */
32
+ export interface PanelMeta {
33
+ group?: string;
34
+ closable?: boolean;
35
+ }
36
+ /**
37
+ * Serializable state for a set of panels: which are `open`, their `z` focus stack (higher renders on
38
+ * top), optional per-panel `pos` overrides, and the `meta` registry (group/closable) captured from
39
+ * the defs so the reducers below need no defs argument. Plain JSON — persist and rehydrate it freely.
40
+ */
41
+ export interface PanelState {
42
+ /** Open panels, keyed by id (`true` for open). Closed panels are absent. */
43
+ open: Record<string, true>;
44
+ /** z / focus order per open panel — the panel with the highest value is focused/on top. */
45
+ z: Record<string, number>;
46
+ /** Per-panel position overrides; remembered across close/reopen. */
47
+ pos: Record<string, PanelPosition>;
48
+ /** Group/closable metadata captured from the defs at construction. */
49
+ meta: Record<string, PanelMeta>;
50
+ /** Next z value to assign on open/focus — monotonic so focus order is deterministic. */
51
+ nextZ: number;
52
+ }
53
+ /**
54
+ * Build the initial {@link PanelState} from the panel defs: panels flagged `initial` start open (in
55
+ * declaration order, respecting group exclusivity), and every def's group/closable is captured into
56
+ * the state so later reducers need no defs.
57
+ *
58
+ * @capability panel-state serializable open/z/position state for a set of toggleable game windows
59
+ */
60
+ export declare function createPanelState(defs: readonly PanelDef[]): PanelState;
61
+ /** Whether panel `id` is currently open. */
62
+ export declare function isOpen(state: PanelState, id: string): boolean;
63
+ /**
64
+ * Open panel `id`, raising it to the top of the focus stack. If it belongs to a `group`, the other
65
+ * open panels in that group are closed (exclusive windows). Opening an already-open panel just
66
+ * refocuses it. Position overrides are preserved.
67
+ *
68
+ * @capability panel-open open a window, raise its z, and enforce exclusive-group membership
69
+ */
70
+ export declare function openPanel(state: PanelState, id: string): PanelState;
71
+ /**
72
+ * Close panel `id`. No-op if it is already closed. Its position override is kept so reopening restores
73
+ * the last placement.
74
+ *
75
+ * @capability panel-close close a window while remembering its last position
76
+ */
77
+ export declare function closePanel(state: PanelState, id: string): PanelState;
78
+ /**
79
+ * Toggle panel `id` — open it (with group exclusivity) if closed, close it if open. The one-key
80
+ * behavior behind a keybind like `B` for the bag.
81
+ *
82
+ * @capability panel-toggle toggle a window open/closed from a single keybind
83
+ */
84
+ export declare function togglePanel(state: PanelState, id: string): PanelState;
85
+ /**
86
+ * Raise open panel `id` to the top of the focus stack (assign it the highest z). No-op if it is
87
+ * closed or already on top, so a pointer-down that refocuses the top window returns the same state.
88
+ *
89
+ * @capability panel-focus raise a window to the top of the focus/z stack
90
+ */
91
+ export declare function focusPanel(state: PanelState, id: string): PanelState;
92
+ /**
93
+ * Close the topmost closable open panel — the ESC handler. Skips panels pinned with `closable: false`
94
+ * and returns the same state when nothing closable is open.
95
+ *
96
+ * @capability panel-close-top close the focused (topmost) closable window — the ESC behavior
97
+ */
98
+ export declare function closeTopPanel(state: PanelState): PanelState;
99
+ /**
100
+ * Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be
101
+ * a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so
102
+ * author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
103
+ *
104
+ * @capability panel-hotkey resolve a keybind (code or key) to the panel id it toggles
105
+ */
106
+ export declare function panelByHotkey(defs: readonly PanelDef[], code: string): string | null;
107
+ /**
108
+ * Set panel `id`'s position override — the drag commit. Does not require the panel to be open, so a
109
+ * caller can pre-place a window before it is shown.
110
+ *
111
+ * @capability panel-move override a window's on-surface position (the drag commit)
112
+ */
113
+ export declare function movePanel(state: PanelState, id: string, pos: PanelPosition): PanelState;
114
+ /**
115
+ * The open panels' defs sorted ascending by z — the render order for a window host (map to elements
116
+ * so the last, highest-z window paints on top). Defs unknown to the state are skipped.
117
+ *
118
+ * @capability panel-ordered-open open panels' defs in z/render order for a window host
119
+ */
120
+ export declare function orderedOpen(state: PanelState, defs: readonly PanelDef[]): PanelDef[];