@jgengine/core 0.11.0 → 0.12.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 (219) hide show
  1. package/CHANGELOG.md +26 -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.d.ts +1 -0
  9. package/dist/combat.js +1 -0
  10. package/dist/devtools/controls.d.ts +38 -0
  11. package/dist/devtools/controls.js +181 -0
  12. package/dist/devtools/devtools.d.ts +5 -197
  13. package/dist/devtools/devtools.js +38 -774
  14. package/dist/devtools/discover.d.ts +32 -0
  15. package/dist/devtools/discover.js +240 -0
  16. package/dist/devtools/fallbackSeams.d.ts +50 -0
  17. package/dist/devtools/fallbackSeams.js +111 -0
  18. package/dist/devtools/frame.d.ts +15 -0
  19. package/dist/devtools/frame.js +247 -0
  20. package/dist/devtools/logs.d.ts +15 -0
  21. package/dist/devtools/logs.js +86 -0
  22. package/dist/devtools/overrides.d.ts +16 -0
  23. package/dist/devtools/overrides.js +66 -0
  24. package/dist/devtools/types.d.ts +212 -0
  25. package/dist/devtools/types.js +1 -0
  26. package/dist/economy/auctionBook.d.ts +122 -0
  27. package/dist/economy/auctionBook.js +213 -0
  28. package/dist/economy/priceHistory.d.ts +44 -0
  29. package/dist/economy/priceHistory.js +89 -0
  30. package/dist/economy/unlockPoints.d.ts +92 -0
  31. package/dist/economy/unlockPoints.js +103 -0
  32. package/dist/editor/commandApply.d.ts +5 -0
  33. package/dist/editor/commandApply.js +755 -0
  34. package/dist/editor/commands.d.ts +48 -3
  35. package/dist/editor/commands.js +40 -754
  36. package/dist/editor/directives.d.ts +94 -0
  37. package/dist/editor/directives.js +206 -0
  38. package/dist/editor/document.d.ts +37 -2
  39. package/dist/editor/document.js +422 -6
  40. package/dist/editor/entityCatalog.d.ts +42 -0
  41. package/dist/editor/entityCatalog.js +101 -0
  42. package/dist/editor/environment.d.ts +74 -0
  43. package/dist/editor/environment.js +169 -0
  44. package/dist/editor/index.d.ts +26 -1
  45. package/dist/editor/index.js +8 -1
  46. package/dist/editor/minimap.d.ts +31 -0
  47. package/dist/editor/minimap.js +73 -0
  48. package/dist/editor/streamer.d.ts +66 -0
  49. package/dist/editor/streamer.js +68 -0
  50. package/dist/editor/types.d.ts +143 -0
  51. package/dist/editor/world.d.ts +136 -0
  52. package/dist/editor/world.js +303 -0
  53. package/dist/game/breeding.d.ts +161 -0
  54. package/dist/game/breeding.js +181 -0
  55. package/dist/game/cameraConfig.d.ts +451 -0
  56. package/dist/game/cameraConfig.js +1 -0
  57. package/dist/game/defineGame.d.ts +16 -9
  58. package/dist/game/defineGame.js +14 -6
  59. package/dist/game/defineSystem.d.ts +2 -0
  60. package/dist/game/defineSystem.js +2 -0
  61. package/dist/game/events.d.ts +28 -0
  62. package/dist/game/playableGame.d.ts +34 -435
  63. package/dist/game/playableGame.js +1 -1
  64. package/dist/game/progression.d.ts +35 -0
  65. package/dist/game/progression.js +19 -0
  66. package/dist/game/quest.js +1 -3
  67. package/dist/game/tribe.d.ts +184 -0
  68. package/dist/game/tribe.js +432 -0
  69. package/dist/gameplay.d.ts +3 -1
  70. package/dist/gameplay.js +3 -1
  71. package/dist/inventory/encumbrance.d.ts +69 -0
  72. package/dist/inventory/encumbrance.js +68 -0
  73. package/dist/item/durability.js +1 -3
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +24 -1
  76. package/dist/movement/movementModel.d.ts +15 -1
  77. package/dist/movement/movementModel.js +33 -15
  78. package/dist/movement/playerMovement.js +102 -2
  79. package/dist/multiplayer/combatSnapshot.js +13 -8
  80. package/dist/multiplayer/presenceModel.d.ts +2 -0
  81. package/dist/multiplayer/presenceModel.js +8 -0
  82. package/dist/physics/drivableVehicle.d.ts +2 -0
  83. package/dist/physics/drivableVehicle.js +1 -1
  84. package/dist/physics/flightDynamics.d.ts +82 -0
  85. package/dist/physics/flightDynamics.js +165 -0
  86. package/dist/physics/gravityField.d.ts +25 -0
  87. package/dist/physics/gravityField.js +41 -0
  88. package/dist/physics/kinematicVehicle.d.ts +87 -0
  89. package/dist/physics/kinematicVehicle.js +193 -11
  90. package/dist/physics/physicsWorld.d.ts +26 -0
  91. package/dist/physics/physicsWorld.js +109 -117
  92. package/dist/physics/vehicleObstacles.d.ts +44 -0
  93. package/dist/physics/vehicleObstacles.js +115 -0
  94. package/dist/random/rng.d.ts +4 -1
  95. package/dist/random/rng.js +4 -1
  96. package/dist/runtime/adapter.d.ts +13 -0
  97. package/dist/runtime/adapter.js +13 -0
  98. package/dist/runtime/context/combat.d.ts +51 -0
  99. package/dist/runtime/context/combat.js +130 -0
  100. package/dist/runtime/context/deathLoot.d.ts +30 -0
  101. package/dist/runtime/context/deathLoot.js +39 -0
  102. package/dist/runtime/context/player.d.ts +48 -0
  103. package/dist/runtime/context/player.js +125 -0
  104. package/dist/runtime/context/scene.d.ts +59 -0
  105. package/dist/runtime/context/scene.js +291 -0
  106. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  107. package/dist/runtime/context/snapshotCodecs.js +26 -0
  108. package/dist/runtime/descriptors/baseline.js +26 -3
  109. package/dist/runtime/descriptors/features.d.ts +5 -0
  110. package/dist/runtime/descriptors/features.js +20 -2
  111. package/dist/runtime/gameContext.d.ts +2 -428
  112. package/dist/runtime/gameContext.js +88 -501
  113. package/dist/runtime/gameContextTypes.d.ts +467 -0
  114. package/dist/runtime/gameContextTypes.js +1 -0
  115. package/dist/runtime/headlessRunner.d.ts +4 -2
  116. package/dist/runtime/headlessRunner.js +1 -0
  117. package/dist/runtime/hostPolicy.d.ts +51 -0
  118. package/dist/runtime/hostPolicy.js +68 -0
  119. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  120. package/dist/runtime/hostedGameRunner.js +1 -0
  121. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  122. package/dist/runtime/hostedWorldSession.js +1 -0
  123. package/dist/runtime/worldSnapshot.d.ts +13 -1
  124. package/dist/runtime/worldSnapshot.js +15 -3
  125. package/dist/scene/assetCatalog.d.ts +5 -0
  126. package/dist/scene/assetSpace.js +1 -3
  127. package/dist/scene/authoredTriggers.d.ts +50 -0
  128. package/dist/scene/authoredTriggers.js +114 -0
  129. package/dist/scene/builtinSceneKinds.js +8 -3
  130. package/dist/scene/colliders.d.ts +58 -0
  131. package/dist/scene/colliders.js +108 -0
  132. package/dist/scene/collisionMesh.d.ts +94 -0
  133. package/dist/scene/collisionMesh.js +699 -0
  134. package/dist/scene/companion.d.ts +147 -0
  135. package/dist/scene/companion.js +193 -0
  136. package/dist/scene/entityStats.d.ts +7 -9
  137. package/dist/scene/entityStats.js +10 -14
  138. package/dist/scene/sceneKinds.d.ts +16 -0
  139. package/dist/scene/sceneKinds.js +4 -0
  140. package/dist/scene/sceneRaycast.js +15 -0
  141. package/dist/stats/spawnLevelStats.d.ts +110 -0
  142. package/dist/stats/spawnLevelStats.js +140 -0
  143. package/dist/stats/statPool.d.ts +70 -0
  144. package/dist/stats/statPool.js +59 -0
  145. package/dist/store/changeSignal.d.ts +3 -0
  146. package/dist/store/changeSignal.js +2 -0
  147. package/dist/store/observableKeyedStore.d.ts +2 -0
  148. package/dist/store/observableKeyedStore.js +1 -0
  149. package/dist/survival/decayMeter.js +1 -3
  150. package/dist/survival/regionHealth.js +1 -3
  151. package/dist/ui/hudLayout.d.ts +5 -0
  152. package/dist/ui/hudLayout.js +17 -1
  153. package/dist/world/authoredEntities.d.ts +29 -0
  154. package/dist/world/authoredEntities.js +29 -0
  155. package/dist/world/authoredObjects.d.ts +15 -1
  156. package/dist/world/authoredObjects.js +13 -1
  157. package/dist/world/authoredSpawn.d.ts +39 -0
  158. package/dist/world/authoredSpawn.js +37 -0
  159. package/dist/world/buildSockets.d.ts +152 -0
  160. package/dist/world/buildSockets.js +188 -0
  161. package/dist/world/cityBlocks.d.ts +152 -0
  162. package/dist/world/cityBlocks.js +670 -0
  163. package/dist/world/cityContent.d.ts +77 -0
  164. package/dist/world/cityContent.js +261 -0
  165. package/dist/world/cityGeometry.d.ts +114 -0
  166. package/dist/world/cityGeometry.js +573 -0
  167. package/dist/world/cityKind.d.ts +314 -0
  168. package/dist/world/cityKind.js +1578 -0
  169. package/dist/world/features.d.ts +18 -3
  170. package/dist/world/features.js +10 -1
  171. package/dist/world/grassKind.js +2 -2
  172. package/dist/world/markers.d.ts +50 -4
  173. package/dist/world/markers.js +49 -0
  174. package/dist/world/massing.js +1 -1
  175. package/dist/world/minimapBake.d.ts +78 -0
  176. package/dist/world/minimapBake.js +208 -0
  177. package/dist/world/minimapTrack.d.ts +17 -0
  178. package/dist/world/minimapTrack.js +22 -0
  179. package/dist/world/pathNetwork.d.ts +159 -0
  180. package/dist/world/pathNetwork.js +684 -0
  181. package/dist/world/place.d.ts +166 -0
  182. package/dist/world/place.js +96 -0
  183. package/dist/world/placeAsset.js +3 -1
  184. package/dist/world/poleLineKind.d.ts +40 -0
  185. package/dist/world/poleLineKind.js +97 -0
  186. package/dist/world/polyline.js +9 -2
  187. package/dist/world/resourceNode.d.ts +109 -0
  188. package/dist/world/resourceNode.js +140 -0
  189. package/dist/world/scatter.js +2 -20
  190. package/dist/world/streets.d.ts +29 -0
  191. package/dist/world/streets.js +55 -0
  192. package/dist/world/terraform.js +58 -88
  193. package/dist/world/terrain.d.ts +45 -0
  194. package/dist/world/terrain.js +78 -0
  195. package/dist/world.d.ts +14 -5
  196. package/dist/world.js +12 -3
  197. package/package.json +1 -1
  198. package/skills/game-design/SKILL.md +28 -0
  199. package/skills/game-design/agents/openai.yaml +4 -0
  200. package/skills/game-design/references/application-playbook.md +170 -0
  201. package/skills/game-design/references/field-guide.md +288 -0
  202. package/skills/jgengine/SKILL.md +7 -4
  203. package/skills/jgengine/api.md +172 -79
  204. package/skills/jgengine/capabilities.md +104 -2
  205. package/skills/jgengine/recipes/minimal-game.md +88 -0
  206. package/skills/jgengine-combat/SKILL.md +5 -1
  207. package/skills/jgengine-combat/api.md +36 -0
  208. package/skills/jgengine-combat/capabilities.md +44 -0
  209. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  210. package/skills/jgengine-combat/reference.md +12 -2
  211. package/skills/jgengine-gameplay/api.md +152 -26
  212. package/skills/jgengine-gameplay/capabilities.md +78 -2
  213. package/skills/jgengine-gameplay/reference-systems.md +1 -5
  214. package/skills/jgengine-world/SKILL.md +13 -0
  215. package/skills/jgengine-world/api.md +419 -15
  216. package/skills/jgengine-world/capabilities.md +108 -2
  217. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  218. package/skills/jgengine-world/reference.md +43 -4
  219. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,88 @@
1
+ # Recipe — minimal game (scaffold → authored scene → win)
2
+
3
+ **What this wires:** the default end-to-end path from nothing to a complete, genre-free
4
+ walk/interact/win game: a scaffold, one `defineGame` config off the happy-path kit, an
5
+ authored scene edited in the browser, one system, one store slot, an empty-canvas HUD,
6
+ and a win condition. Every new game starts here; add combat, multiplayer, or deeper
7
+ world features only after this loop plays.
8
+
9
+ ## 1. Scaffold
10
+
11
+ `npx jgengine create <name>` (inside the engine monorepo: `bun run new:game "<id-or-title>"`).
12
+ The file set is thin and already runs:
13
+
14
+ - `src/main.tsx` — mounts `GameHost` with `editor={() => import("@jgengine/editor")}`; the
15
+ host owns the F2+E / `?mode=editor` editor summon and the dev save endpoint.
16
+ - `src/game.config.ts` — the whole game definition (below).
17
+ - `src/editor.scene.json` + `src/editorLayers.ts` — the authored scene document, normalized
18
+ with `normalizeEditorLayers`. All placement lives here, never as coordinates in code.
19
+ - `src/loop.ts` and `src/game/ui/GameUI.tsx` — systems and HUD.
20
+
21
+ ## 2. Define the game — one import surface
22
+
23
+ ```ts
24
+ // game.config.ts — everything below comes from the kit; deeper paths are for outgrowing it.
25
+ import { defineGame } from "@jgengine/shell/gameKit";
26
+
27
+ import { editorLayers } from "./editorLayers";
28
+ import { GameUI } from "./game/ui/GameUI";
29
+ import { onNewPlayer, systems } from "./loop";
30
+
31
+ export const game = defineGame({
32
+ name: "my-game",
33
+ // Binding movement actions makes the shell drive the walk controller — the game walks day one.
34
+ input: { moveForward: ["KeyW"], moveBack: ["KeyS"], moveLeft: ["KeyA"], moveRight: ["KeyD"], interact: ["KeyE"] },
35
+ systems,
36
+ loop: { onNewPlayer },
37
+ GameUI,
38
+ editorLayers, // the authored scene auto-mounts; the player spawns at its player_spawn marker
39
+ });
40
+ // Solo game: omit `multiplayer` entirely — offline is the shell default.
41
+ ```
42
+
43
+ **World is optional and thin.** Omitting `world` is valid — a pure UI/rules game is not a
44
+ spatial place. When the game *is* a place, declare only substrate + laws with `world()` from
45
+ `@jgengine/core/world/place` (e.g. `{ id, ground: { mode: "flat", size: { x: Infinity, z: Infinity } }, physics: { gravity: -24 } }`,
46
+ or `mode: "board"` for 2D). Never seed, sky, or vegetation in `world` — the engine renders its
47
+ default sky until you author one in the editor, and foliage is editor scatter over the ground.
48
+
49
+
50
+ ```ts
51
+ // loop.ts — one store slot for run state, one system for the rules tick.
52
+ import { defineStore, defineSystem, authoredSpawnPosition, type GameContext } from "@jgengine/shell/gameKit";
53
+
54
+ import { editorLayers } from "./editorLayers";
55
+
56
+ export const run = defineStore("run", { collected: 0, won: false });
57
+
58
+ export const systems = [
59
+ defineSystem({
60
+ id: "rules",
61
+ tick: { type: "fixed" },
62
+ update(ctx: GameContext) {
63
+ // System-check win alternative: read/update `run` here (e.g. win at collected >= 3).
64
+ if (run.read(ctx).collected >= 3) run.update(ctx, (s) => ({ ...s, won: true }));
65
+ },
66
+ }),
67
+ ];
68
+
69
+ export function onNewPlayer(ctx: GameContext): void {
70
+ ctx.scene.entity.spawn("player", { id: ctx.player.userId, position: authoredSpawnPosition(editorLayers) ?? [0, 0, 0] });
71
+ }
72
+ ```
73
+
74
+ `GameUI` starts as an empty `HudCanvas` (`useHudLayout` + `HudCanvas` from the kit). That is
75
+ intentional: **every game owns its UI**. Write a short UI art direction, then build custom
76
+ panels for this pitch — do not ship stock drop-in widgets (`StatBar`, `Hotbar`, `Coins`, glass
77
+ frames) as the product face. Reach for engine pieces only as headless data, layout, or
78
+ interaction models under game-owned chrome (see `jgengine-ui`).
79
+
80
+ ## 3. Author, play, win
81
+
82
+ 1. `bun dev`, then F2+E opens the editor on `editor.scene.json` — move the spawn, place
83
+ props and trigger markers, Ctrl+S saves back to the document.
84
+ 2. The starter scene ships a goal marker with `{ on: "enter", action: "win" }` — walking
85
+ into it wins with zero game code. Author more rules the same way (triggers in the
86
+ editor), or read volumes yourself via the kit's `collectAuthoredTriggers(editorLayers)`.
87
+ 3. Prove it: `bun test` asserts the document (spawn, goal), and `jgengine-verify` covers
88
+ screenshot and gameplay evidence before any "it works" claim.
@@ -11,6 +11,11 @@ This skill owns combat resolution: target policy, combat stats, teams/factions a
11
11
 
12
12
  Search [capabilities.md](capabilities.md), then use [api.md](api.md) for signatures and [reference.md](reference.md) for complete pipelines.
13
13
 
14
+ Existing projects keep their resource state and follow the
15
+ [portable stat-pool recipe](recipes/portable-stat-pools.md). Adapt the caller's
16
+ store through `StatPoolAccess`; do not require `StatValueMap`, `GameContext`, or
17
+ a parallel entity store.
18
+
14
19
  ## Canonical pipeline
15
20
 
16
21
  1. Acquire eligible candidates from a bounded spatial source.
@@ -31,4 +36,3 @@ Compose stages through registration/policy seams; adding a damage type, effect,
31
36
  - Reward generation and allocation are different operations, especially in multiplayer.
32
37
  - Compose multiple loot pools with `createLootPipeline` (ordered stages, gates, fallbacks, roll modifiers, per-drop provenance) over `lootTable`; keep genre concepts (world/dedicated/boss/luck/pity) in game-space stages and modifiers, never in core.
33
38
  - UI bars, reticles, and feedback route to `jgengine-ui`.
34
-
@@ -88,6 +88,12 @@
88
88
  - `ShotOriginPolicy` (type): type ShotOriginPolicy = | { kind: "converge"; muzzle?: EntityPosition; height?: number } | { kind: "eye"; height?: number } | { kind: "legacy" } | { kind: "entity" } | { kind: "entityOffset"; offset: EntityPosition } | { kind: "muzzle"; offset?: EntityPosition } | { kind: "camera"; origin: EntityPos… — How a shot's world-space origin (and optional direction) is resolved before prediction/settlement. - `converge` — the shot leaves the gun `muzzle` yet still passes through whatever the shooter's sightline (crosshair) covers: origin is the muzzle offset, direction is bent from the muzzle to the eye ray's aim point. The projectile system's default for a free `{ yaw, pitch }` aim, so a bullet visibly comes from the barrel without missing the reticle. Needs a scene raycast to find the aim point (`convergeShot`); a bare `resolveShot` degrades to a straight muzzle ray. Passes an explicit `{ origin, direction }` aim through untouched. - `eye` — `aim.origin` when present, else the shooter's entity position raised to eye height; the shot traces the shooter's sightline, so what the crosshair covers is what gets hit. - `legacy` — `aim.origin` when present, else the shooter's raw entity position (feet). - `entity` — always the shooter's entity position. - `entityOffset` / `muzzle` — entity-local offset rotated by the shooter's yaw (muzzle on a weapon model). - `camera` — explicit camera/reticle world origin (and optional direction override). - `world` — absolute world origin.
89
89
  - `SpawnProvider` (type): type SpawnProvider = ( params: Readonly<Record<string, unknown>>, ctx: SpawnProviderContext, ) => readonly EncounterSpawnRequest[] — A spawn provider: given the phase's `params` and context, returns the spawn requests to emit on enter. Deterministic; reads authored scene points from `ctx.spawnPoints` rather than embedding coordinates.
90
90
  - `SpawnProviderContext` (interface): interface SpawnProviderContext — Evaluation context a spawn provider receives when its phase is entered.
91
+ - `StatPool` (interface): interface StatPool — Serializable state for any bounded named resource. The caller defines what the resource means.
92
+ - `StatPoolAccess` (interface): interface StatPoolAccess<TOwnerId extends string = string, TStatId extends string = string> — Structural bridge to caller-owned resource state. Implement it over an ECS, Redux/Zustand store, plain object, database-backed model, or JGengine's native entity stats without moving ownership into the SDK.
93
+ - `StatPoolAccessResult` (type): type StatPoolAccessResult = | ({ status: "ok" } & StatPoolChange) | { status: "rejected"; reason: string } — Outcome of changing a pool through {@link StatPoolAccess}.
94
+ - `StatPoolChange` (interface): interface StatPoolChange — Inspectable result of a bounded pool change.
95
+ - `StatPoolInput` (interface): interface StatPoolInput — Input used to create a normalized pool. `current` defaults to `max`; `min` defaults to zero.
96
+ - `StatPoolPatch` (interface): interface StatPoolPatch — Partial update applied without mutating the previous pool.
91
97
  - `Stats` (interface): interface Stats<TStat extends string> — ⚠ undocumented
92
98
  - `StatusApplicationOutcome` (interface): interface StatusApplicationOutcome — The result of one status application: the outcome, the resulting instance (if any), and the roll.
93
99
  - `StatusApplicationSpec` (interface): interface StatusApplicationSpec — A serializable, effect-agnostic description of a status a hit tries to apply: an opaque `status` id the game routes to its own effect/DoT pipeline, a base landing `chance`, and the duration/ticks/magnitude/attribution/stacking data the resolver needs. The engine never interprets `status` — it is a reference, not a built-in condition.
@@ -109,7 +115,9 @@
109
115
  - `applyRenderAnimationEvent` (function): function applyRenderAnimationEvent(cues: EntityRenderCues, event: string): EntityRenderCues — Applies a `entity.animation` event (`"fire"` / `"reload"` / `"reloadEnd"`, or any game-defined name) to the cue set. Unknown event names are a no-op.
110
116
  - `applyRenderDeathEvent` (function): function applyRenderDeathEvent(cues: EntityRenderCues): EntityRenderCues — Marks the cue set dead after `entity.died`; sticky for the lifetime of the render component.
111
117
  - `applyRenderHitEvent` (function): function applyRenderHitEvent(cues: EntityRenderCues): EntityRenderCues — Marks a `combat.hitReaction` pulse; the caller clears `hit` again after its own pulse window.
118
+ - `applyStatPoolDelta` (function): function applyStatPoolDelta<TOwnerId extends string, TStatId extends string>(access: StatPoolAccess<TOwnerId, TStatId>, ownerId: TOwnerId, statId: TStatId, amount: number): StatPoolAccessResult — Read, transition, and write a caller-owned pool through the structural adapter. Positive amounts increase the resource; negative amounts decrease it. The adapter receives a complete replacement value, never a hidden mutation.
112
119
  - `attackMeta` (function): function attackMeta(tags: readonly AttackTag[], extra?: Omit<AttackMeta, "tags">): AttackMeta — ⚠ undocumented
120
+ - `changeStatPool` (function): function changeStatPool(pool: StatPool, amount: number): StatPoolChange — Pure bounded delta transition with the exact applied amount and boundary flags.
113
121
  - `convergeShot` (function): function convergeShot(deps: ShotOriginDeps, from: string, aim: Aim, range: number, sightHit: (origin: EntityPosition, direction: EntityPosition) => EntityPosition | null, muzzleOffset?: EntityPosition): ResolvedShot | null — Resolves a `converge` shot with scene knowledge: fires from the gun muzzle but bends the direction so the shot passes through the aim point the shooter's eye ray covers. `sightHit` casts the eye ray and returns where it lands (first impact), or `null` to fall back to a point `range` metres down the sightline. A `{ origin, direction }` aim is passed through unchanged (nothing to converge).
114
122
  - `counters` (function): function counters(meta: AttackMeta, move: CounterMove): boolean — ⚠ undocumented
115
123
  - `createAbilityKit` (function): function createAbilityKit(configs: readonly AbilitySlotConfig[], options: AbilityKitOptions = {}): AbilityKit — A bar of cooldown-gated abilities the player fires by slot, tracking readiness and cooldown per ability.
@@ -134,6 +142,7 @@
134
142
  - `createRegenShield` (function): function createRegenShield(config: RegenShieldConfig): RegenShield — Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit.
135
143
  - `createResourcePool` (function): function createResourcePool(config: ResourcePoolConfig): ResourcePool — A regenerating resource pool — mana, stamina, energy — that actions spend from and that refills over time.
136
144
  - `createStaggerMeter` (function): function createStaggerMeter(config: StaggerMeterConfig): StaggerMeter — Build a stagger/poise gauge from landed hits toward a break threshold that staggers the target.
145
+ - `createStatPool` (function): function createStatPool(input: StatPoolInput): StatPool — Normalize caller data into a complete plain-data pool.
137
146
  - `createStats` (function): function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat> — A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
138
147
  - `createThresholdTracker` (function): function createThresholdTracker(config: ThresholdCrossingConfig): ThresholdTracker — Track a numeric source against named thresholds and emit ordered crossing events with direction, hysteresis, once/repeat policy, and skipped-threshold handling. Deterministic and serializable; drives phase transitions, armor breaks, resource warnings, and any other threshold consumer.
139
148
  - `encounterProgress` (function): function encounterProgress<TData>(state: EncounterState<TData>): { completed: number; total: number; done: boolean; } — Progress across the whole tree: how many phases have completed out of the total node count, plus whether the encounter has finished. Counts injected phases.
@@ -145,6 +154,7 @@
145
154
  - `isBlockable` (function): function isBlockable(meta: AttackMeta): boolean — ⚠ undocumented
146
155
  - `isDodgeable` (function): function isDodgeable(meta: AttackMeta): boolean — ⚠ undocumented
147
156
  - `isParryable` (function): function isParryable(meta: AttackMeta): boolean — ⚠ undocumented
157
+ - `patchStatPool` (function): function patchStatPool(pool: StatPool, patch: StatPoolPatch): StatPool — Apply new bounds/current to a pool, clamping the result and leaving the input untouched.
148
158
  - `phaseStatus` (function): function phaseStatus<TData>(state: EncounterState<TData>, id: string): PhaseStatus | null — The runtime status of a phase by id (pending/active/complete), or null if the id is unknown — for driving per-phase HUD ticks and objective checklists.
149
159
  - `resistanceScale` (function): function resistanceScale<TCategory extends string = string, TProperty extends string = string>(matrix: ResistanceMatrix<TCategory, TProperty>, category: TCategory | string, targetProperties: readonly (TProperty | string)[]): number — ⚠ undocumented
150
160
  - `resolveClaim` (function): function resolveClaim(pool: ClaimablePool, claimantId: string, options?: { nowMs?: number; private?: boolean }): ClaimOutcome — Resolve an authoritative claim against a pool. Idempotent: re-claiming an already-owned pool returns the same grants without re-awarding, a losing claimant gets no grants, and eligibility, reservation, and expiry are all enforced here — the authority, not UI hiding, decides ownership. Returns the updated (serializable) pool plus the grants owed.
@@ -540,6 +550,19 @@
540
550
  - `CheckResult` (interface): interface CheckResult — ⚠ undocumented
541
551
  - `rollCheck` (function): function rollCheck(input: CheckInput, rng: () => number = Math.random): CheckResult — Resolve a tabletop-style pass/fail roll against a target number with modifiers and crit/fumble bands.
542
552
 
553
+ ## @jgengine/core/stats/spawnLevelStats
554
+
555
+ - `CreatureStatInstance` (interface): interface CreatureStatInstance<TStat extends string> — A single creature's serializable stat record — a reference to its species (`speciesId`), the current `level`, the per-stat count of distributed points, and how many post-capture domestic levels have been spent. Plain data: round-trips through JSON, save/load, and multiplayer sync. Resolve display values with {@link resolveSpawnStats}.
556
+ - `SpawnSpeciesDef` (type): type SpawnSpeciesDef<TStat extends string> = Record<TStat, SpawnStatDef> — A species' stat table: every stat the creature owns keyed to its {@link SpawnStatDef}. The key order is the deterministic order random points are distributed over, so a serialized species definition reproduces the same rolls under the same rng sequence.
557
+ - `SpawnStatDef` (interface): interface SpawnStatDef — Per-stat growth definition for one species. `base` is the level-1 value of the stat; `growth` is how much a single distributed point adds, expressed as a fraction of `base` — so a point on a `growth: 0.1` stat is worth +10% of base. The resolved value is `base * (1 + points * growth)`.
558
+ - `applyCaptureEffectiveness` (function): function applyCaptureEffectiveness<TStat extends string>(species: SpawnSpeciesDef<TStat>, instance: CreatureStatInstance<TStat>, effectiveness: number, rng: () => number): CaptureResult<TStat> — Apply a capture at the given effectiveness: compute bonus levels via {@link captureBonusLevels}, distribute those extra points randomly with the injected rng, and return the post-capture instance (level raised to the tamed total) alongside the granted bonus levels and tamed level. The input instance is not mutated.
559
+ - `captureBonusLevels` (function): function captureBonusLevels(level: number, effectiveness: number): number — Bonus levels granted by a capture at the given effectiveness: `floor(level * clamp01(effectiveness) / 2)`. A perfect (1.0) capture grants half the wild level; a botched (0) capture grants none. Effectiveness outside `[0,1]` clamps.
560
+ - `resolveSpawnStats` (function): function resolveSpawnStats<TStat extends string>(species: SpawnSpeciesDef<TStat>, instance: CreatureStatInstance<TStat>): Record<TStat, number> — Resolve an instance's live stat values from its distributed points: `value = base * (1 + points * growth)` per stat. Stats with zero points return their base.
561
+ - `restoreSpawnInstance` (function): function restoreSpawnInstance<TStat extends string>(data: CreatureStatInstance<TStat>): CreatureStatInstance<TStat> — Rehydrate a creature stat instance from decoded data (e.g. `JSON.parse` output), returning a fresh deep copy the caller owns. Inverse of {@link snapshotSpawnInstance}.
562
+ - `rollSpawnInstance` (function): function rollSpawnInstance<TStat extends string>(speciesId: string, species: SpawnSpeciesDef<TStat>, level: number, rng: () => number): CreatureStatInstance<TStat> — Roll a fresh wild creature at `level`: distribute `(level - 1)` points randomly across the species' stats using the injected rng and return a serializable {@link CreatureStatInstance}. Deterministic — the same rng sequence yields the same point spread. Levels below 1 clamp to 1 (no points). Use `seededRng` from `../random/rng` for a persisted, reproducible stream.
563
+ - `snapshotSpawnInstance` (function): function snapshotSpawnInstance<TStat extends string>(instance: CreatureStatInstance<TStat>): CreatureStatInstance<TStat> — Deep-copy an instance into a detached, serializable record — the transport/save counterpart of {@link restoreSpawnInstance}. Copies the points map so later edits don't leak into the snapshot.
564
+ - `spendDomesticLevel` (function): function spendDomesticLevel<TStat extends string>(instance: CreatureStatInstance<TStat>, stat: TStat, options?: SpendLevelOptions): SpendLevelResult<TStat> — Spend one earned post-capture level into `stat`, adding a single growth increment (one point) and raising both `level` and `domesticLevels`. When `maxDomesticLevels` is set and already reached, the spend is refused (`spent: false`) and the instance is returned untouched. Pure: returns a new instance rather than mutating the input.
565
+
543
566
  ## @jgengine/core/stats/statModifiers
544
567
 
545
568
  - `CreateStatsOptions` (interface): interface CreateStatsOptions — ⚠ undocumented
@@ -547,3 +570,16 @@
547
570
  - `StatModifierSet` (type): type StatModifierSet<TStat extends string> = Partial<Record<TStat, StatModifier>> — ⚠ undocumented
548
571
  - `Stats` (interface): interface Stats<TStat extends string> — ⚠ undocumented
549
572
  - `createStats` (function): function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat> — A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
573
+
574
+ ## @jgengine/core/stats/statPool
575
+
576
+ - `StatPool` (interface): interface StatPool — Serializable state for any bounded named resource. The caller defines what the resource means.
577
+ - `StatPoolAccess` (interface): interface StatPoolAccess<TOwnerId extends string = string, TStatId extends string = string> — Structural bridge to caller-owned resource state. Implement it over an ECS, Redux/Zustand store, plain object, database-backed model, or JGengine's native entity stats without moving ownership into the SDK.
578
+ - `StatPoolAccessResult` (type): type StatPoolAccessResult = | ({ status: "ok" } & StatPoolChange) | { status: "rejected"; reason: string } — Outcome of changing a pool through {@link StatPoolAccess}.
579
+ - `StatPoolChange` (interface): interface StatPoolChange — Inspectable result of a bounded pool change.
580
+ - `StatPoolInput` (interface): interface StatPoolInput — Input used to create a normalized pool. `current` defaults to `max`; `min` defaults to zero.
581
+ - `StatPoolPatch` (interface): interface StatPoolPatch — Partial update applied without mutating the previous pool.
582
+ - `applyStatPoolDelta` (function): function applyStatPoolDelta<TOwnerId extends string, TStatId extends string>(access: StatPoolAccess<TOwnerId, TStatId>, ownerId: TOwnerId, statId: TStatId, amount: number): StatPoolAccessResult — Read, transition, and write a caller-owned pool through the structural adapter. Positive amounts increase the resource; negative amounts decrease it. The adapter receives a complete replacement value, never a hidden mutation.
583
+ - `changeStatPool` (function): function changeStatPool(pool: StatPool, amount: number): StatPoolChange — Pure bounded delta transition with the exact applied amount and boundary flags.
584
+ - `createStatPool` (function): function createStatPool(input: StatPoolInput): StatPool — Normalize caller data into a complete plain-data pool.
585
+ - `patchStatPool` (function): function patchStatPool(pool: StatPool, patch: StatPoolPatch): StatPool — Apply new bounds/current to a pool, clamping the result and leaving the input untouched.
@@ -12,6 +12,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
12
12
 
13
13
  - `createAntiOneShotPolicy` (function) · `import { createAntiOneShotPolicy } from "@jgengine/core/combat"`
14
14
 
15
+ ## capture-bonus-levels — convert wild level and capture effectiveness into bonus levels
16
+
17
+ - `captureBonusLevels` (function) · `import { captureBonusLevels } from "@jgengine/core/stats/spawnLevelStats"`
18
+
19
+ ## capture-effectiveness-tame — apply capture effectiveness to grant and distribute bonus levels
20
+
21
+ - `applyCaptureEffectiveness` (function) · `import { applyCaptureEffectiveness } from "@jgengine/core/stats/spawnLevelStats"`
22
+
15
23
  ## cast-bar — run a channeled cast timer that movement or damage can interrupt
16
24
 
17
25
  - `createCastRunner` (function) · `import { createCastRunner } from "@jgengine/core/combat"`
@@ -56,6 +64,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
56
64
 
57
65
  - `rollCheck` (function) · `import { rollCheck } from "@jgengine/core/combat"`
58
66
 
67
+ ## domestic-level-spend — spend an earned post-capture level into a chosen stat under a cap
68
+
69
+ - `spendDomesticLevel` (function) · `import { spendDomesticLevel } from "@jgengine/core/stats/spawnLevelStats"`
70
+
59
71
  ## dot-field — track stacking damage-over-time effects and drain damage each frame
60
72
 
61
73
  - `createDotField` (function) · `import { createDotField } from "@jgengine/core/combat"`
@@ -125,6 +137,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
125
137
 
126
138
  - `createDefensiveWindow` (function) · `import { createDefensiveWindow } from "@jgengine/core/combat"`
127
139
 
140
+ ## portable-stat-pool — apply deterministic bounded resource changes through a caller-owned stat adapter
141
+
142
+ - `applyStatPoolDelta` (function) · `import { applyStatPoolDelta } from "@jgengine/core/combat"`
143
+
128
144
  ## projectiles — spawn and advance projectiles with travel and hit resolution
129
145
 
130
146
  - `createProjectileSystem` (function) · `import { createProjectileSystem } from "@jgengine/core/combat"`
@@ -147,6 +163,22 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
147
163
  - `filterOutcomeFor` (function) · `import { filterOutcomeFor } from "@jgengine/core/combat"`
148
164
  - `resolveClaim` (function) · `import { resolveClaim } from "@jgengine/core/combat"`
149
165
 
166
+ ## spawn-instance-restore — rehydrate a creature stat instance from decoded snapshot data
167
+
168
+ - `restoreSpawnInstance` (function) · `import { restoreSpawnInstance } from "@jgengine/core/stats/spawnLevelStats"`
169
+
170
+ ## spawn-instance-snapshot — deep-copy a creature stat instance into a serializable record
171
+
172
+ - `snapshotSpawnInstance` (function) · `import { snapshotSpawnInstance } from "@jgengine/core/stats/spawnLevelStats"`
173
+
174
+ ## spawn-level-roll — distribute a wild creature's spawn level into random per-stat points
175
+
176
+ - `rollSpawnInstance` (function) · `import { rollSpawnInstance } from "@jgengine/core/stats/spawnLevelStats"`
177
+
178
+ ## spawn-stat-values — resolve a creature's per-stat values from its distributed points
179
+
180
+ - `resolveSpawnStats` (function) · `import { resolveSpawnStats } from "@jgengine/core/stats/spawnLevelStats"`
181
+
150
182
  ## stagger-meter — accumulate an ailment buildup (bleed, freeze) until it procs
151
183
 
152
184
  - `createBuildupMeter` (function) · `import { createBuildupMeter } from "@jgengine/core/combat"`
@@ -156,6 +188,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
156
188
 
157
189
  - `createStats` (function) · `import { createStats } from "@jgengine/core/combat"`
158
190
 
191
+ ## stat-pool — create a serializable current/min/max pool for any caller-named resource
192
+
193
+ - `createStatPool` (function) · `import { createStatPool } from "@jgengine/core/combat"`
194
+
195
+ ## stat-pool-change — immutably increase or decrease any named bounded resource with clamp evidence
196
+
197
+ - `changeStatPool` (function) · `import { changeStatPool } from "@jgengine/core/combat"`
198
+
199
+ ## stat-pool-patch — immutably retune a bounded resource's current value or bounds
200
+
201
+ - `patchStatPool` (function) · `import { patchStatPool } from "@jgengine/core/combat"`
202
+
159
203
  ## status-application — resolve a data-defined status application with chance, matchup scaling, and stacking policy
160
204
 
161
205
  - `resolveStatusApplication` (function) · `import { resolveStatusApplication } from "@jgengine/core/combat"`
@@ -0,0 +1,122 @@
1
+ # Use JGengine stat pools with an existing save model
2
+
3
+ Use this recipe when a project already owns its entities and persistence but
4
+ needs deterministic bounded resources such as shields, energy, stamina, XP, or
5
+ durability. Resource names and meaning remain caller data; the portable
6
+ contract has no privileged health field.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ bun add @jgengine/core
12
+ ```
13
+
14
+ Import the focused, renderer-free module:
15
+
16
+ ```ts
17
+ import {
18
+ applyStatPoolDelta,
19
+ createStatPool,
20
+ type StatPool,
21
+ type StatPoolAccess,
22
+ } from "@jgengine/core/stats/statPool";
23
+ ```
24
+
25
+ `@jgengine/core` has no runtime dependencies and does not require a JGengine
26
+ entity store, game loop, renderer, React provider, or backend.
27
+
28
+ ## Minimal adapter
29
+
30
+ Suppose the existing project saves resources inline on its own units:
31
+
32
+ ```ts
33
+ interface UnitSave {
34
+ id: string;
35
+ resources: Record<string, StatPool>;
36
+ }
37
+
38
+ interface WorldSave {
39
+ units: Record<string, UnitSave>;
40
+ }
41
+
42
+ const world: WorldSave = {
43
+ units: {
44
+ rover: {
45
+ id: "rover",
46
+ resources: {
47
+ energy: createStatPool({ current: 8, max: 10 }),
48
+ durability: createStatPool({ max: 40 }),
49
+ },
50
+ },
51
+ },
52
+ };
53
+
54
+ const resources: StatPoolAccess = {
55
+ get(ownerId, statId) {
56
+ return world.units[ownerId]?.resources[statId] ?? null;
57
+ },
58
+ set(ownerId, statId, next) {
59
+ const unit = world.units[ownerId];
60
+ if (unit !== undefined) unit.resources[statId] = next;
61
+ },
62
+ };
63
+ ```
64
+
65
+ The adapter performs structural reads and complete replacement writes. It does
66
+ not expose the caller's store implementation or hand mutation authority to the
67
+ engine.
68
+
69
+ ## Apply a resource change
70
+
71
+ ```ts
72
+ const spent = applyStatPoolDelta(resources, "rover", "energy", -3);
73
+
74
+ if (spent.status === "ok") {
75
+ console.log(spent.pool.current); // 5
76
+ console.log(spent.applied); // -3 (after clamping)
77
+ console.log(spent.hitMin); // false
78
+ }
79
+ ```
80
+
81
+ Positive amounts increase a pool and negative amounts decrease it. The result
82
+ records the previous and next values, the exact clamped amount, and whether a
83
+ bound was reached. Missing owners or stat ids return a rejected result without
84
+ writing.
85
+
86
+ For reducer-style stores, call the pure `changeStatPool(pool, amount)` or
87
+ `patchStatPool(pool, patch)` transition inside the project's existing action
88
+ and commit the returned `pool` through that store's normal update path.
89
+
90
+ ## Save and restore
91
+
92
+ `StatPool` is plain JSON data, so keep it in the project's existing save:
93
+
94
+ ```ts
95
+ const serialized = JSON.stringify(world);
96
+ const restored = JSON.parse(serialized) as WorldSave;
97
+ ```
98
+
99
+ After restore, point the same adapter at `restored`. There is no second JGengine
100
+ snapshot to reconcile, no registry to rebuild, and no required migration away
101
+ from the caller's schema. If the existing schema uses different field names,
102
+ translate only at `get`/`set`.
103
+
104
+ ## Ownership
105
+
106
+ The existing project owns resource ids, entity ids, storage layout,
107
+ persistence, replication, authority, UI, scheduling, and the decision to apply
108
+ a change. JGengine owns normalization, clamping, pure transitions, and the
109
+ small structural access contract. Native JGengine entity stats implement the
110
+ same portable behavior through their existing API.
111
+
112
+ ## Common traps
113
+
114
+ - Do not copy caller resources into a parallel `StatValueMap`; adapt the state
115
+ where it already lives.
116
+ - Do not mutate the object returned by `get`. Commit the complete returned
117
+ `pool` through `set` or the caller's reducer/action.
118
+ - Pool changes are signed: positive restores/fills, negative spends/drains.
119
+ - Keep formulas, regeneration schedules, damage meaning, and resource names in
120
+ caller code. A stat pool only enforces bounds.
121
+ - Persist `current`, `max`, and `min` together so a restored value is clamped
122
+ against the same bounds.
@@ -2,6 +2,17 @@
2
2
 
3
3
  Reference module for the [`jgengine-combat` API](SKILL.md) skill. Load this when you need the combat surface — effects, projectiles, death, feel, and abilities.
4
4
 
5
+ ## Portable named resources
6
+
7
+ `StatPool` (`@jgengine/core/stats/statPool`) is the plain
8
+ `{ current, max, min }` model for any caller-named bounded resource. Existing
9
+ projects implement the two-method `StatPoolAccess` over their own ECS, reducer,
10
+ or save model, then use `applyStatPoolDelta`; reducer-style integrations use
11
+ the pure `createStatPool` / `patchStatPool` / `changeStatPool` transitions.
12
+ Native scene entity stats are a compatibility bridge over the same model. See
13
+ the [portable stat-pool recipe](recipes/portable-stat-pools.md) for complete
14
+ imports, adapter code, ownership, and JSON save/restore.
15
+
5
16
  ## Effects and projectiles
6
17
 
7
18
  Effect ids are **game-defined strings**. Magnitudes **drain** stats: positive subtracts down `receive.<effect>.order` (spilling to the next stat in the order), negative restores. Heals pass a negative amount (`via: { amount: -flashHeal }`, typically read from a `weapon.heal` stat).
@@ -25,7 +36,7 @@ fireProjectile({ from, via, aim, effect }) // → shotId (pending)
25
36
  settleProjectile(shotId) // authoritative → { at, hits } | rejection
26
37
  ```
27
38
 
28
- `Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Default entity hitbox is a body-covering AABB (feet to ~1.8m). Objects with a `visual.scale` get a matching grounded physical body automatically; explicit catalog `colliders`/`halfExtents` override. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority.
39
+ `Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Hitboxes auto-fit the rendered model: an entity kind mapped in `entityModels` (or an object catalog id in `objectModels`) gets a grounded AABB matching its model's measured bounds — footprint x height composed with `scale`/`targetHeight` exactly like the renderer — so the damage box equals the visual without hand-tuning. Unmodeled entities keep the body-covering humanoid AABB default (feet to ~1.8m); unmodeled objects with a `visual.scale` get a matching grounded physical body. Catalog assets opted into `COLLISION_MESH_ASSET_IDS` (`@jgengine/assets`) go one step further: the fit raycasts the asset's actual triangles (BVH over a quantized mesh shipped in the index), so a shot through a torus hole or archway passes clean instead of hitting the fitted box — same shape on client and headless host. Explicit catalog `colliders`/`halfExtents` and per-instance `setColliders` always override the fit. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority.
29
40
 
30
41
  Raycasts are **object-aware**: the default raycast checks placed scene objects as well as entities, discriminated by `RaycastHit.kind` (`"entity" | "object"` — an `ObjectRaycastHit` also carries `catalogId`). A crate or wall between shooter and target blocks or absorbs the shot instead of every projectile passing through scenery; supply `ProjectileSystemDeps.objects` (`{ list(), halfExtents?(catalogId) }`, matching `ObjectStore.list()` structurally) to opt in, with a `[0.5, 0.5, 0.5]` half-extent default per object.
31
42
 
@@ -95,4 +106,3 @@ Genre systems layered over the same effects/projectiles/targeting/loot primitive
95
106
  - **Spawn providers** — spawning is one optional node adapter: `spawn: { kind, params }` invoked on enter. `BUILTIN_SPAWN_PROVIDERS.points` reads a named authored scene point list from `ctx.spawnPoints` (coordinates stay in the scene, referenced by key — never embedded in the sequence); `list` echoes inline requests. Register catalog/director-backed providers via `config.spawnProviders`.
96
107
  - **Injection & scripted control** — `injectPhase(state, phase, { after | before | childOf })` inserts a phase or branch at runtime (e.g. a boss after the final wave) into the serializable tree without disturbing active/completed nodes; `forceCompletePhase` completes the active phase regardless of its predicate for scripted skips and failure/retry, restoring the authored predicate afterward.
97
108
 
98
-