@jgengine/core 0.11.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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
package/CHANGELOG.md CHANGED
@@ -12,6 +12,58 @@ Agents building on the published SDK can also read this programmatically:
12
12
  same data as typed values, so an updater can diff its installed version against
13
13
  the latest and surface the migration steps.
14
14
 
15
+ ## 0.13.0
16
+
17
+ ### Migrate
18
+
19
+ - **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
20
+ - Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
21
+
22
+ ### Added
23
+
24
+ - **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
25
+ - **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
26
+ - **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
27
+ - **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
28
+ - **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
29
+ - **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
30
+ - **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
31
+ - **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
32
+ - **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
33
+ - **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
34
+
35
+ ### Changed
36
+
37
+ - Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
38
+ - RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
39
+ - Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
40
+
41
+ ## 0.12.0
42
+
43
+ ### Migrate
44
+
45
+ - **Author games through `defineGame` from `@jgengine/shell`** — it is the single public authoring entry (and carries the define-game capability tag). Core's constructor was renamed `defineGame` → `defineGameDefinition` (`@jgengine/core/game/defineGame`); only hosts and tooling call it. If you imported `defineGame` from core, switch the import to `@jgengine/shell`. `GamePlayer`/`GamePlayerShell` are `@internal` now — `GameHost` is the documented mount, and it owns the editor summon (`?mode=editor`, F2+E, dev save endpoint), so delete any per-game editor bootstrap.
46
+ - **Declare your world with `world()`** (`@jgengine/core/world/place`): `world({ id, ground: { mode, size, surface? }, physics? })` with `flat` / `round` / `voxel` / `board` ground. New scaffolds emit a thin infinite flat place instead of the old 96 m meadow. `environment()` still works but is demoted to the legacy consumption path for editor-written scene data — **nothing required** for existing games; new worlds should use `world()` and author all dressing in the editor.
47
+ - Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
48
+
49
+ ### Added
50
+
51
+ - **World = place: `world()` API** (#1178) — a world is substrate + laws, not a coded diorama. `@jgengine/core/world/place` ships `world({ id, ground: { mode, size, surface?, generator? }, physics? })`; size is discriminated by mode (flat `{x,z}` with `Infinity` axes, round `{radius}`, voxel generator domain, board `{x,y}` cells — TS and runtime both reject mismatches). `ground.surface` carries matter/feel laws (metal vs slime vs felt) read by the same rule systems; per-place `physics` resolves over the game default; multiple worlds per game are first-class. The shell renders place substrates (`PlaceScene`) under the engine default sky; seeds derive from world id + save via `seedForPlace`.
52
+ - **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
53
+ - **Portable generic stat pools** — `@jgengine/core/stats/statPool`: bounded named pools (health, energy, heat, ammo…) with the entity-stats integration rebuilt on them; recipe in skill `jgengine-combat`.
54
+ - **Auction-house economy primitives** (#1086) — `@jgengine/core/economy/auctionBook` (timed bid auctions, escrowed bids, outbid refunds, optional buyout, anti-snipe close extension, seller settlement minus house cut) plus market price history.
55
+ - **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
56
+ - **Unified seed-driven path network + city generation v2** (#1103, #1106) — streets/blocks/buildings with grid-ness, curviness, branching and block-size dials; zone bands, weighted building-class mixes, massing pieces, balance presets, junction-centered clustering, road-derived block polygons and parcels. Shell renders realistic roads: real intersections, curbs, lane lines, tunnels, and roads riding bridge decks.
57
+ - **Physical fidelity** — mesh-accurate hitboxes (shots pass through holes in concave models, #1075), auto-fit entity/object colliders from measured model bounds (#1072), movement obstruction reading fitted boxes + compound-box openings (#1077).
58
+ - **Editor overhaul** (#1110) — materials / lighting / scripting / animation-timeline / multiplayer workspaces, command palette, minimap bake wired end-to-end (#1036), real offscreen GLB thumbnails in the content browser, hierarchy drag-drop + keyboard + context menus + per-object visibility/lock, multi-select inspector, console RPC, asset drag-place, ortho projection, pivot modes, play chrome.
59
+ - **Editor authoring pipeline** — author entity definitions and starter catalogs (#1012), author catalogs/schema fields in the Data tab (#1043), promote an authored scene folder into a runnable game (#1011), authored triggers promoted to a shared primitive with goal/win rules (#1013), asset import rewrites the game's `game/assets.ts` (#1042).
60
+ - **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
61
+
62
+ ### Changed
63
+
64
+ - `npx jgengine create` scaffolds a thin infinite flat `world()` place; the editor `blankWorld` no longer pre-bakes seeded grass. Dressing belongs in `editor.scene.json`.
65
+ - Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
66
+
15
67
  ## 0.11.0
16
68
 
17
69
  ### Migrate
@@ -0,0 +1,103 @@
1
+ import type { NavPoint } from "../nav/navGrid.js";
2
+ import { type RandomSeed } from "../random/rng.js";
3
+ /** One species that ambiently populates a region: how heavily it is favoured and its alive ceiling. */
4
+ export interface PopulationSpeciesEntry {
5
+ /** Opaque species/creature id the engine never interprets. */
6
+ species: string;
7
+ /** Relative pick weight among species below their cap (default 1). */
8
+ weight?: number;
9
+ /** Target/maximum number of this species alive in the region at once. */
10
+ cap: number;
11
+ }
12
+ /** Ambient population budget for a single region: its species ceilings, refill timing, and spawn geometry. */
13
+ export interface PopulationRegionConfig {
14
+ /** Opaque region id the engine never interprets. */
15
+ region: string;
16
+ species: readonly PopulationSpeciesEntry[];
17
+ /** Seconds a freed slot waits before it becomes a spawn candidate (overrides the director default). */
18
+ respawnDelay?: number;
19
+ /** Candidate spawn locations; when present each request is tagged with one. */
20
+ spawnPoints?: readonly NavPoint[];
21
+ }
22
+ /** Full ambient-population configuration: per-region species budgets plus global refill limits and PRNG seed. */
23
+ export interface PopulationDirectorConfig {
24
+ regions: readonly PopulationRegionConfig[];
25
+ /** Default respawn delay in game-seconds for regions without their own (default 0 — refill on the next tick). */
26
+ respawnDelay?: number;
27
+ /** Upper bound on spawn requests emitted per {@link PopulationDirector.tick} call (default 32). */
28
+ maxSpawnsPerTick?: number;
29
+ /** Seed for the persisted, serializable PRNG cursor (default 1). */
30
+ seed?: number;
31
+ }
32
+ /** A single ambient spawn the game should realise, refilling a region toward its species caps. */
33
+ export interface PopulationSpawnRequest {
34
+ region: string;
35
+ species: string;
36
+ /** Chosen spawn location when the region declares `spawnPoints`. */
37
+ point?: NavPoint;
38
+ }
39
+ /** Per-call spawn-point selection hook, letting a game apply bias (distance from players, etc.) at tick time. */
40
+ export interface PopulationTickContext {
41
+ /**
42
+ * Choose a spawn point for a request. Receives the region id, its configured candidates, and a
43
+ * deterministic roll in `[0, 1)` drawn from the persisted cursor. Return `null` to leave the
44
+ * request unplaced. When omitted, a candidate is picked uniformly from the roll.
45
+ */
46
+ pickSpawnPoint?(region: string, candidates: readonly NavPoint[], roll: number): NavPoint | null;
47
+ }
48
+ /** Live census of alive counts keyed `region -> species -> count`, used to reconcile the director to reality. */
49
+ export type PopulationCensus = Record<string, Record<string, number>>;
50
+ /**
51
+ * A queued refill slot awaiting its {@link PopulationDirectorState.elapsed} deadline.
52
+ * @internal
53
+ */
54
+ export interface PopulationRespawnSlot {
55
+ region: string;
56
+ readyAt: number;
57
+ }
58
+ /**
59
+ * Opaque, JSON-serializable persistence for a {@link PopulationDirector}. Round-trip it through
60
+ * `snapshot`/`hydrate`; never read or mutate the fields directly.
61
+ */
62
+ export interface PopulationDirectorState {
63
+ /** Accumulated game-seconds. */
64
+ elapsed: number;
65
+ /** Believed alive counts, `region -> species -> count`. */
66
+ alive: Record<string, Record<string, number>>;
67
+ /** Pending refill slots, each ready at its `readyAt` game-second. */
68
+ queue: readonly PopulationRespawnSlot[];
69
+ /** Opaque PRNG cursor — round-trip as part of the state, never read or set it directly. */
70
+ rng: RandomSeed;
71
+ }
72
+ /** Stateful ambient population manager for one world; drive it with `tick` and lifecycle notifications. */
73
+ export interface PopulationDirector {
74
+ /**
75
+ * Advance by `dt` game-seconds and emit spawn requests for refill slots whose delay has elapsed,
76
+ * up to the per-tick cap. Only touches the bounded respawn queue — never scans the world.
77
+ */
78
+ tick(dt: number, ctx?: PopulationTickContext): PopulationSpawnRequest[];
79
+ /** Register that a creature left the region (killed/despawned/tamed), freeing a slot to refill after the delay. */
80
+ notifyRemoved(region: string, species: string, id?: string): void;
81
+ /** Register a creature the game introduced itself (not via a request), so caps account for it. */
82
+ notifySpawned(region: string, species: string, id?: string): void;
83
+ /** Snap believed alive counts to a live census and re-queue any deficit toward the caps. */
84
+ reconcile(census: PopulationCensus): void;
85
+ /** Believed alive count for a `(region, species)` pair. */
86
+ alive(region: string, species: string): number;
87
+ /** Serializable copy of the full director state (deep, safe to persist). */
88
+ snapshot(): PopulationDirectorState;
89
+ /** Replace the director state from a previously taken (and possibly JSON round-tripped) snapshot. */
90
+ hydrate(state: PopulationDirectorState): void;
91
+ }
92
+ /**
93
+ * Ambient open-world population director. Holds a target creature population per region and refills
94
+ * it over time, distinct from wave/horde spawning: instead of a budget draining down a wave, each
95
+ * region has per-species caps and a timed respawn queue. Removing a creature frees a slot; after a
96
+ * respawn delay `tick` emits weighted spawn requests (bounded per tick) that refill toward the caps
97
+ * without ever exceeding them. Species are chosen through a persisted, serializable {@link RandomSeed}
98
+ * cursor, so the same seed and the same lifecycle events always yield the same requests. Regions are
99
+ * independent, spawn points are optional, and the whole state round-trips through `snapshot`/`hydrate`.
100
+ *
101
+ * @capability population-director ambient per-region creature population manager with timed weighted respawns and caps
102
+ */
103
+ export declare function createPopulationDirector(config: PopulationDirectorConfig): PopulationDirector;
@@ -0,0 +1,190 @@
1
+ import { randomSeedFrom, stepRandomSeed } from "../random/rng.js";
2
+ const DEFAULT_MAX_SPAWNS_PER_TICK = 32;
3
+ function clamp01(value) {
4
+ return value < 0 ? 0 : value > 1 ? 1 : value;
5
+ }
6
+ function cloneState(state) {
7
+ const alive = {};
8
+ for (const region of Object.keys(state.alive))
9
+ alive[region] = { ...state.alive[region] };
10
+ return {
11
+ elapsed: state.elapsed,
12
+ alive,
13
+ queue: state.queue.map((slot) => ({ region: slot.region, readyAt: slot.readyAt })),
14
+ rng: state.rng,
15
+ };
16
+ }
17
+ /**
18
+ * Ambient open-world population director. Holds a target creature population per region and refills
19
+ * it over time, distinct from wave/horde spawning: instead of a budget draining down a wave, each
20
+ * region has per-species caps and a timed respawn queue. Removing a creature frees a slot; after a
21
+ * respawn delay `tick` emits weighted spawn requests (bounded per tick) that refill toward the caps
22
+ * without ever exceeding them. Species are chosen through a persisted, serializable {@link RandomSeed}
23
+ * cursor, so the same seed and the same lifecycle events always yield the same requests. Regions are
24
+ * independent, spawn points are optional, and the whole state round-trips through `snapshot`/`hydrate`.
25
+ *
26
+ * @capability population-director ambient per-region creature population manager with timed weighted respawns and caps
27
+ */
28
+ export function createPopulationDirector(config) {
29
+ const regions = new Map();
30
+ for (const region of config.regions)
31
+ regions.set(region.region, region);
32
+ const maxSpawnsPerTick = config.maxSpawnsPerTick ?? DEFAULT_MAX_SPAWNS_PER_TICK;
33
+ const defaultDelay = config.respawnDelay ?? 0;
34
+ let state = {
35
+ elapsed: 0,
36
+ alive: {},
37
+ queue: [],
38
+ rng: randomSeedFrom(config.seed ?? 1),
39
+ };
40
+ const totalCap = (region) => {
41
+ let sum = 0;
42
+ for (const entry of region.species)
43
+ sum += entry.cap;
44
+ return sum;
45
+ };
46
+ const totalAlive = (regionId) => {
47
+ const counts = state.alive[regionId];
48
+ if (counts === undefined)
49
+ return 0;
50
+ let sum = 0;
51
+ for (const key of Object.keys(counts))
52
+ sum += counts[key];
53
+ return sum;
54
+ };
55
+ const queuedCount = (regionId) => {
56
+ let sum = 0;
57
+ for (const slot of state.queue)
58
+ if (slot.region === regionId)
59
+ sum += 1;
60
+ return sum;
61
+ };
62
+ const delayFor = (region) => region.respawnDelay ?? defaultDelay;
63
+ const aliveFor = (regionId) => {
64
+ let counts = state.alive[regionId];
65
+ if (counts === undefined) {
66
+ counts = {};
67
+ state.alive[regionId] = counts;
68
+ }
69
+ return counts;
70
+ };
71
+ /** Top up the queue so `alive + queued` reaches the region cap, appending ready-at slots. */
72
+ const ensureDeficitQueued = (region, readyAt) => {
73
+ const deficit = totalCap(region) - totalAlive(region.region) - queuedCount(region.region);
74
+ if (deficit <= 0)
75
+ return;
76
+ const next = state.queue.slice();
77
+ for (let i = 0; i < deficit; i += 1)
78
+ next.push({ region: region.region, readyAt });
79
+ state.queue = next;
80
+ };
81
+ // Prime each region to fill toward its caps on the first ticks.
82
+ for (const region of config.regions) {
83
+ aliveFor(region.region);
84
+ ensureDeficitQueued(region, 0);
85
+ }
86
+ const pickSpecies = (region) => {
87
+ const counts = state.alive[region.region] ?? {};
88
+ let total = 0;
89
+ const eligible = [];
90
+ for (const entry of region.species) {
91
+ if ((counts[entry.species] ?? 0) >= entry.cap)
92
+ continue;
93
+ eligible.push(entry);
94
+ total += entry.weight ?? 1;
95
+ }
96
+ if (eligible.length === 0 || total <= 0)
97
+ return null;
98
+ const [roll, nextRng] = stepRandomSeed(state.rng);
99
+ state.rng = nextRng;
100
+ let cursor = roll * total;
101
+ for (const entry of eligible) {
102
+ cursor -= entry.weight ?? 1;
103
+ if (cursor <= 0)
104
+ return entry.species;
105
+ }
106
+ return eligible[eligible.length - 1].species;
107
+ };
108
+ const choosePoint = (region, ctx) => {
109
+ const points = region.spawnPoints;
110
+ if (points === undefined || points.length === 0)
111
+ return undefined;
112
+ const [roll, nextRng] = stepRandomSeed(state.rng);
113
+ state.rng = nextRng;
114
+ if (ctx?.pickSpawnPoint !== undefined) {
115
+ const chosen = ctx.pickSpawnPoint(region.region, points, roll);
116
+ return chosen ?? undefined;
117
+ }
118
+ return points[Math.min(points.length - 1, Math.floor(clamp01(roll) * points.length))];
119
+ };
120
+ return {
121
+ tick(dt, ctx) {
122
+ if (dt <= 0)
123
+ return [];
124
+ state.elapsed += dt;
125
+ const now = state.elapsed;
126
+ const requests = [];
127
+ const remaining = [];
128
+ for (const slot of state.queue) {
129
+ if (requests.length >= maxSpawnsPerTick || slot.readyAt > now) {
130
+ remaining.push(slot);
131
+ continue;
132
+ }
133
+ const region = regions.get(slot.region);
134
+ if (region === undefined)
135
+ continue; // region no longer configured — drop the slot
136
+ const species = pickSpecies(region);
137
+ if (species === null)
138
+ continue; // region already at cap (filled externally) — drop the slot
139
+ aliveFor(region.region)[species] = (state.alive[region.region][species] ?? 0) + 1;
140
+ const request = { region: region.region, species };
141
+ const point = choosePoint(region, ctx);
142
+ if (point !== undefined)
143
+ request.point = point;
144
+ requests.push(request);
145
+ }
146
+ state.queue = remaining;
147
+ return requests;
148
+ },
149
+ notifyRemoved(region, species, _id) {
150
+ const counts = state.alive[region];
151
+ if (counts !== undefined) {
152
+ const current = counts[species] ?? 0;
153
+ if (current > 0)
154
+ counts[species] = current - 1;
155
+ }
156
+ const regionConfig = regions.get(region);
157
+ if (regionConfig === undefined)
158
+ return;
159
+ const next = state.queue.slice();
160
+ next.push({ region, readyAt: state.elapsed + delayFor(regionConfig) });
161
+ state.queue = next;
162
+ },
163
+ notifySpawned(region, species, _id) {
164
+ if (!regions.has(region))
165
+ return;
166
+ const counts = aliveFor(region);
167
+ counts[species] = (counts[species] ?? 0) + 1;
168
+ },
169
+ reconcile(census) {
170
+ for (const region of config.regions) {
171
+ const observed = census[region.region] ?? {};
172
+ const counts = {};
173
+ for (const entry of region.species)
174
+ counts[entry.species] = observed[entry.species] ?? 0;
175
+ state.alive[region.region] = counts;
176
+ }
177
+ for (const region of config.regions)
178
+ ensureDeficitQueued(region, state.elapsed);
179
+ },
180
+ alive(region, species) {
181
+ return state.alive[region]?.[species] ?? 0;
182
+ },
183
+ snapshot() {
184
+ return cloneState(state);
185
+ },
186
+ hydrate(next) {
187
+ state = cloneState(next);
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Composable pursuit/aggro step: the "I have a target — now chase it, stop at reach, hit it on a
3
+ * cooldown, and break off if led too far from home" loop that melee mobs, RTS units, and stationary
4
+ * turrets all re-implement by hand. This owns exactly that state machine and the per-entity attack
5
+ * cooldown; target *selection* stays separate (see {@link ./targetAcquisition}), and every side
6
+ * effect — move, attack, break off — is performed by the caller in response to the returned action.
7
+ *
8
+ * Deterministic and allocation-free: distances are measured by the caller and passed in as scalars,
9
+ * the only state is one serializable number ({@link PursuitState.attackCooldown}) advanced in place,
10
+ * and the returned {@link PursuitAction} is a string literal. No object is built per entity per step,
11
+ * and nothing here scans the world — feed it a target you already resolved.
12
+ */
13
+ /** The action a pursuit step resolves to; the caller performs the matching side effect. */
14
+ export type PursuitAction =
15
+ /** Home distance exceeded the leash — abandon the target and return home. */
16
+ "leash"
17
+ /** Out of range — advance toward the target. */
18
+ | "pursue"
19
+ /** In range and the cooldown is ready — perform the attack, then {@link armPursuit}. */
20
+ | "attack"
21
+ /** In range but the cooldown has not elapsed — hold (face the target, idle, etc.). */
22
+ | "wait"
23
+ /** No engageable target this step (distance was `null`). */
24
+ | "idle";
25
+ /**
26
+ * When the attack cooldown ticks down.
27
+ * - `"always"` (default): every step, like a wall-clock weapon timer that keeps counting during the
28
+ * chase, so the entity can swing the instant it reaches its target.
29
+ * - `"inRange"`: only while within `stopDistance`, so the timer freezes during the chase and the
30
+ * full interval is paid out after each contact.
31
+ */
32
+ export type CooldownMode = "always" | "inRange";
33
+ /** Per-entity cooldown state owned by the primitive. One serializable number; no methods. */
34
+ export interface PursuitState {
35
+ /** Seconds remaining until the next attack may fire. Advanced toward `0` by {@link advancePursuit}. */
36
+ attackCooldown: number;
37
+ }
38
+ /** A fresh pursuit state, ready to attack immediately (`attackCooldown` at `0`). */
39
+ export declare function createPursuitState(attackCooldown?: number): PursuitState;
40
+ /** Set the cooldown after an attack fires (clamped at `0`). Call on the step that returned `"attack"`. */
41
+ export declare function armPursuit(state: PursuitState, intervalSeconds: number): void;
42
+ /**
43
+ * Advance one pursuit step for a single entity, mutating `state.attackCooldown`, and return the
44
+ * action the caller should perform. All spatial input is scalar and measured this frame:
45
+ *
46
+ * - `targetDistance` — self→target distance, or `null` when there is no engageable target (`"idle"`).
47
+ * - `stopDistance` — attack once within this distance; pursue while beyond it.
48
+ * - `homeDistance` / `leashRange` — optional leash checked first: when `homeDistance > leashRange`
49
+ * the step is `"leash"`. Leave at their defaults (`-Infinity` / `+Infinity`) to disable leashing.
50
+ * - `cooldownMode` — see {@link CooldownMode}.
51
+ *
52
+ * On `"attack"` the caller applies the hit and calls {@link armPursuit} with the next interval.
53
+ *
54
+ * @capability pursuit chase-to-reach cooldown-gated attack with optional leash-to-home; owns the attack cooldown
55
+ */
56
+ export declare function advancePursuit(state: PursuitState, dt: number, targetDistance: number | null, stopDistance: number, cooldownMode?: CooldownMode, homeDistance?: number, leashRange?: number): PursuitAction;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Composable pursuit/aggro step: the "I have a target — now chase it, stop at reach, hit it on a
3
+ * cooldown, and break off if led too far from home" loop that melee mobs, RTS units, and stationary
4
+ * turrets all re-implement by hand. This owns exactly that state machine and the per-entity attack
5
+ * cooldown; target *selection* stays separate (see {@link ./targetAcquisition}), and every side
6
+ * effect — move, attack, break off — is performed by the caller in response to the returned action.
7
+ *
8
+ * Deterministic and allocation-free: distances are measured by the caller and passed in as scalars,
9
+ * the only state is one serializable number ({@link PursuitState.attackCooldown}) advanced in place,
10
+ * and the returned {@link PursuitAction} is a string literal. No object is built per entity per step,
11
+ * and nothing here scans the world — feed it a target you already resolved.
12
+ */
13
+ /** A fresh pursuit state, ready to attack immediately (`attackCooldown` at `0`). */
14
+ export function createPursuitState(attackCooldown = 0) {
15
+ return { attackCooldown };
16
+ }
17
+ /** Set the cooldown after an attack fires (clamped at `0`). Call on the step that returned `"attack"`. */
18
+ export function armPursuit(state, intervalSeconds) {
19
+ state.attackCooldown = intervalSeconds > 0 ? intervalSeconds : 0;
20
+ }
21
+ function tickCooldown(state, dt) {
22
+ if (state.attackCooldown > 0) {
23
+ const next = state.attackCooldown - dt;
24
+ state.attackCooldown = next > 0 ? next : 0;
25
+ }
26
+ }
27
+ /**
28
+ * Advance one pursuit step for a single entity, mutating `state.attackCooldown`, and return the
29
+ * action the caller should perform. All spatial input is scalar and measured this frame:
30
+ *
31
+ * - `targetDistance` — self→target distance, or `null` when there is no engageable target (`"idle"`).
32
+ * - `stopDistance` — attack once within this distance; pursue while beyond it.
33
+ * - `homeDistance` / `leashRange` — optional leash checked first: when `homeDistance > leashRange`
34
+ * the step is `"leash"`. Leave at their defaults (`-Infinity` / `+Infinity`) to disable leashing.
35
+ * - `cooldownMode` — see {@link CooldownMode}.
36
+ *
37
+ * On `"attack"` the caller applies the hit and calls {@link armPursuit} with the next interval.
38
+ *
39
+ * @capability pursuit chase-to-reach cooldown-gated attack with optional leash-to-home; owns the attack cooldown
40
+ */
41
+ export function advancePursuit(state, dt, targetDistance, stopDistance, cooldownMode = "always", homeDistance = Number.NEGATIVE_INFINITY, leashRange = Number.POSITIVE_INFINITY) {
42
+ if (cooldownMode === "always")
43
+ tickCooldown(state, dt);
44
+ if (homeDistance > leashRange)
45
+ return "leash";
46
+ if (targetDistance === null)
47
+ return "idle";
48
+ if (targetDistance > stopDistance)
49
+ return "pursue";
50
+ if (cooldownMode === "inRange")
51
+ tickCooldown(state, dt);
52
+ return state.attackCooldown <= 0 ? "attack" : "wait";
53
+ }
@@ -5,7 +5,7 @@
5
5
  * advanced composition, but this module intentionally exposes common authored
6
6
  * concepts without requiring callers to understand the repository layout.
7
7
  */
8
- export { defineGame, type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type PhysicsConfig, type GameServerConfig, } from "./game/defineGame.js";
8
+ export { type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type PhysicsConfig, type GameServerConfig, } from "./game/defineGame.js";
9
9
  export { lootTable, type LootTableDef, type LootEntry, type Drop, } from "./game/lootTable.js";
10
10
  export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, type WorldFeature, } from "./world/features.js";
11
11
  export { seededRng, seededStreams } from "./random/rng.js";
package/dist/authoring.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * advanced composition, but this module intentionally exposes common authored
6
6
  * concepts without requiring callers to understand the repository layout.
7
7
  */
8
- export { defineGame, } from "./game/defineGame.js";
8
+ export {} from "./game/defineGame.js";
9
9
  export { lootTable, } from "./game/lootTable.js";
10
10
  export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, } from "./world/features.js";
11
11
  export { seededRng, seededStreams } from "./random/rng.js";
@@ -1,5 +1,5 @@
1
1
  import type { EntityPosition } from "../scene/entityStore.js";
2
- import { type StatValueMap } from "../scene/entityStats.js";
2
+ import { type StatPool, type StatPoolAccess } from "../stats/statPool.js";
3
3
  export interface ReceiveRule {
4
4
  order: string[];
5
5
  modifiers?: Record<string, number>;
@@ -58,7 +58,13 @@ export interface AreaTarget {
58
58
  export declare function resolveAreaTargets(spatial: CombatSpatialDeps, input: AreaTargetInput, accept?: (instanceId: string) => boolean): AreaTarget[];
59
59
  export interface EffectSystemDeps {
60
60
  resolveReceive(instanceId: string): ReceiveMap | null | undefined;
61
- resolveStats(instanceId: string): StatValueMap | undefined;
61
+ /** Preferred portable bridge to caller-owned resource state. */
62
+ statPools?: StatPoolAccess;
63
+ /**
64
+ * Legacy native bridge retained for source compatibility. New integrations
65
+ * should provide {@link statPools} and keep their own state model.
66
+ */
67
+ resolveStats?(instanceId: string): Record<string, StatPool> | undefined;
62
68
  getStat(itemId: string, stat: string): number | null;
63
69
  spatial: CombatSpatialDeps;
64
70
  drainStatByEffect?: Record<string, string>;
@@ -70,8 +76,9 @@ export interface EffectSystem {
70
76
  applyEffect(input: EffectInput): EffectResult[];
71
77
  }
72
78
  /**
73
- * Apply, stack, and tick timed status effects — buffs, debuffs, DoTs — on entities.
79
+ * Resolve direct or area resource effects through caller-owned stat pools,
80
+ * including ordered absorption, restoration, modifiers, and lethal context.
74
81
  *
75
- * @capability status-effects apply and tick timed status effects and buffs on entities
82
+ * @capability status-effects commit direct or area effects through caller-owned stat pools with ordered spillover and lethal results
76
83
  */
77
84
  export declare function createEffectSystem(deps: EffectSystemDeps): EffectSystem;
@@ -1,5 +1,5 @@
1
- import { applyPoolDelta } from "../scene/entityStats.js";
2
1
  import { distanceBetween } from "../scene/spatial.js";
2
+ import { applyStatPoolDelta } from "../stats/statPool.js";
3
3
  export function resolveAreaTargets(spatial, input, accept) {
4
4
  const falloff = input.falloff ?? "none";
5
5
  const los = input.los ?? true;
@@ -22,11 +22,24 @@ export function resolveAreaTargets(spatial, input, accept) {
22
22
  }
23
23
  const DEFAULT_DRAIN_STAT = "damage";
24
24
  /**
25
- * Apply, stack, and tick timed status effects — buffs, debuffs, DoTs — on entities.
25
+ * Resolve direct or area resource effects through caller-owned stat pools,
26
+ * including ordered absorption, restoration, modifiers, and lethal context.
26
27
  *
27
- * @capability status-effects apply and tick timed status effects and buffs on entities
28
+ * @capability status-effects commit direct or area effects through caller-owned stat pools with ordered spillover and lethal results
28
29
  */
29
30
  export function createEffectSystem(deps) {
31
+ const statPools = deps.statPools ?? {
32
+ get(instanceId, statId) {
33
+ return deps.resolveStats?.(instanceId)?.[statId] ?? null;
34
+ },
35
+ set(instanceId, statId, next) {
36
+ const stats = deps.resolveStats?.(instanceId);
37
+ if (stats === undefined)
38
+ return false;
39
+ stats[statId] = next;
40
+ return true;
41
+ },
42
+ };
30
43
  function resolveRule(instanceId, effect) {
31
44
  return deps.resolveReceive(instanceId)?.[effect] ?? null;
32
45
  }
@@ -49,13 +62,12 @@ export function createEffectSystem(deps) {
49
62
  const rule = resolveRule(instanceId, effect);
50
63
  if (rule === null)
51
64
  return "not-receivable";
52
- const stats = deps.resolveStats(instanceId);
53
- if (stats === undefined)
65
+ if (deps.statPools === undefined && deps.resolveStats?.(instanceId) === undefined)
54
66
  return "unknown-instance";
55
67
  const restorative = magnitude !== undefined && magnitude < 0;
56
68
  const anyPoolHasHeadroom = rule.order.some((statId) => {
57
- const stat = stats[statId];
58
- if (stat === undefined)
69
+ const stat = statPools.get(instanceId, statId);
70
+ if (stat === null)
59
71
  return false;
60
72
  return restorative ? stat.current < stat.max : stat.current > stat.min;
61
73
  });
@@ -63,7 +75,7 @@ export function createEffectSystem(deps) {
63
75
  return "pools-depleted";
64
76
  return null;
65
77
  }
66
- function drainPools(instanceId, effect, rule, stats, drainMagnitude) {
78
+ function drainPools(instanceId, effect, rule, drainMagnitude) {
67
79
  const applied = [];
68
80
  const lastStatId = rule.order[rule.order.length - 1];
69
81
  let remaining = drainMagnitude;
@@ -71,14 +83,10 @@ export function createEffectSystem(deps) {
71
83
  for (const statId of rule.order) {
72
84
  if (remaining === 0)
73
85
  break;
74
- const before = stats[statId];
75
- if (before === undefined)
76
- continue;
77
- const result = applyPoolDelta(stats, statId, -remaining);
86
+ const result = applyStatPoolDelta(statPools, instanceId, statId, -remaining);
78
87
  if (result.status === "rejected")
79
88
  continue;
80
- stats[statId] = result.stat;
81
- const delta = result.stat.current - before.current;
89
+ const delta = result.applied;
82
90
  if (delta !== 0)
83
91
  applied.push({ statId, delta });
84
92
  remaining += delta;
@@ -89,13 +97,12 @@ export function createEffectSystem(deps) {
89
97
  }
90
98
  function applyTo(instanceId, effect, via, from, scale) {
91
99
  const rule = resolveRule(instanceId, effect);
92
- const stats = deps.resolveStats(instanceId);
93
- if (rule === null || stats === undefined)
100
+ if (rule === null)
94
101
  return null;
95
102
  const drainMagnitude = modifiedDrainMagnitude(baseDrainMagnitude(effect, via) * scale, rule);
96
103
  if (canReceive(instanceId, effect, drainMagnitude) !== null)
97
104
  return null;
98
- const result = drainPools(instanceId, effect, rule, stats, drainMagnitude);
105
+ const result = drainPools(instanceId, effect, rule, drainMagnitude);
99
106
  if (result.lethal)
100
107
  deps.onLethal?.(instanceId, { from, via, effect });
101
108
  return result;
@@ -17,6 +17,12 @@ export interface MagazineConfig {
17
17
  */
18
18
  reserve?: number | MagazineReserve;
19
19
  }
20
+ /** Complete serializable state for a {@link Magazine}; `reserve: null` means infinite reserve. */
21
+ export interface MagazineSnapshot {
22
+ loaded: number;
23
+ reloadElapsedMs: number | null;
24
+ reserve: number | null;
25
+ }
20
26
  /**
21
27
  * A per-weapon magazine: discrete loaded rounds, a timed reload that refills from a reserve pool,
22
28
  * and the reserve-pool interaction itself — the primitive that replaces hand-rolling mag size,
@@ -45,6 +51,13 @@ export interface Magazine {
45
51
  addReserve(amount: number): void;
46
52
  /** Advance the reload timer; completes the reload once `reloadMs` has elapsed. */
47
53
  tick(dtSeconds: number): void;
54
+ /** Return detached plain data for save, rollback, replay, or worker transfer. */
55
+ snapshot(): MagazineSnapshot;
56
+ /**
57
+ * Restore decoded snapshot data. Returns false without changing loaded/reload
58
+ * state when reserve kinds differ or an external reserve cannot reconcile.
59
+ */
60
+ restore(snapshot: MagazineSnapshot): boolean;
48
61
  }
49
62
  /**
50
63
  * Builds a {@link Magazine}: discrete loaded ammo, a timed reload, and reserve-pool interaction.