@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -16,6 +16,8 @@ export interface ModelDims {
16
16
  }
17
17
  export interface ModelAssetRef {
18
18
  url: string;
19
+ /** Preserve a composed prefab's authored local origin instead of recentering its measured bounds. */
20
+ anchor?: "center" | "origin";
19
21
  /** Measured at asset reindex: horizontal footprint, footprint center, and lowest Y in model space (pre-scale). Lets the shell auto-center and ground-snap corner-pivot kit models. */
20
22
  dims?: ModelDims;
21
23
  /** Opt-in compact triangle mesh extracted at asset reindex (see {@link "scene/collisionMesh".CollisionMeshData}) — feeds mesh-accurate hitboxes for concave models. */
@@ -1,7 +1,10 @@
1
1
  import type { Blackboard, DecisionAbort, DecisionAction, DecisionGraphRuntime } from "../ai/decisionGraph.js";
2
2
  import { type InterestGateState } from "../ai/interestScheduler.js";
3
+ import { type PursuitState } from "../ai/pursuit.js";
4
+ import { type ThreatTable, type ThreatTableState } from "../ai/threat.js";
3
5
  import { type PathFollowState, type PathProgress, type Waypoint } from "../nav/pathFollow.js";
4
6
  import type { GameContext } from "../runtime/gameContext.js";
7
+ import type { GameDefinition } from "../game/defineGame.js";
5
8
  /** Whether a behavior instance advances and writes pose (`active`), is temporarily suspended retaining
6
9
  * state (`paused`), or is held off until re-enabled (`disabled`). */
7
10
  export type BehaviorStatus = "active" | "paused" | "disabled";
@@ -25,7 +28,7 @@ export declare function registerBehaviorActions(id: string, actions: Record<stri
25
28
  /**
26
29
  * Catch-up policy applied when a paused instance resumes. `freeze` (default, deterministic) discards
27
30
  * the time spent paused; `advance` silently fast-forwards the instance by the paused duration without
28
- * emitting intermediate pose writes. Only patrol honors `advance`; wander treats it as `freeze`.
31
+ * emitting intermediate pose writes. Only patrol honors `advance`; wander/pursue treat it as `freeze`.
29
32
  */
30
33
  export type BehaviorResumePolicy = "freeze" | "advance";
31
34
  /** Serializable snapshot of one behavior instance — round-trips exactly through
@@ -37,7 +40,7 @@ export type BehaviorSnapshot = {
37
40
  readonly kind: "wander";
38
41
  readonly origin: Waypoint;
39
42
  readonly target: Waypoint | null;
40
- } | {
43
+ } | PursueBehaviorSnapshot | {
41
44
  readonly kind: "decisionGraph";
42
45
  readonly graph: ReturnType<DecisionGraphRuntime["snapshot"]>;
43
46
  readonly gate: InterestGateState;
@@ -45,10 +48,22 @@ export type BehaviorSnapshot = {
45
48
  /** Seconds accrued since the last think. */
46
49
  readonly pending?: number;
47
50
  };
51
+ /** Plain pursuit progress, including its original spawn home and scheduled elapsed time. */
52
+ export interface PursueBehaviorSnapshot {
53
+ readonly kind: "pursue";
54
+ readonly home: Waypoint;
55
+ readonly targetId: string | null;
56
+ readonly explicitTargetId: string | null;
57
+ readonly returning: boolean;
58
+ readonly pursuit: PursuitState;
59
+ readonly gate: InterestGateState;
60
+ readonly pending: number;
61
+ readonly threat?: ThreatTableState;
62
+ }
48
63
  /** Inspection readout for editor/debug tooling, from {@link BehaviorControl.inspect}/{@link BehaviorControl.list}. */
49
64
  export interface BehaviorInspection {
50
65
  id: string;
51
- kind: "patrol" | "wander" | "decisionGraph";
66
+ kind: "patrol" | "wander" | "decisionGraph" | "pursue";
52
67
  status: BehaviorStatus;
53
68
  reason: string | null;
54
69
  }
@@ -82,15 +97,19 @@ export interface BehaviorControl {
82
97
  list(): BehaviorInspection[];
83
98
  /** The live, persistent blackboard of a decision graph instance for perception and game systems to write facts into, or `null`. */
84
99
  blackboard(id: string): Blackboard | null;
100
+ /** Optional pursuit threat table; explicit `setTarget` wins over taunt/threat and proximity. */
101
+ threat(id: string): ThreatTable | null;
102
+ /** Clear pursuit target/cooldown/threat and reset its cadence, retaining the original home. */
103
+ reset(id: string): boolean;
85
104
  }
86
105
  /**
87
- * Advance every spawned entity carrying a `patrol` or `wander` {@link BehaviorDescriptor} one tick — the
106
+ * Advance every spawned entity carrying a patrol, wander, pursuit or decision graph behavior one tick — the
88
107
  * engine reads the descriptor, keeps the per-entity nav state itself, and poses the entity, so ambient
89
108
  * traffic and idle NPC routes are register-once (attach the behavior at spawn) instead of a per-game
90
109
  * per-frame `advancePathFollow` + `setPose` loop. Instances that are paused or disabled through
91
110
  * {@link behaviorControl} retain their state and are skipped. The shell/host call this each frame; a game never does.
92
111
  *
93
- * @capability behavior-tick auto-advance patrol/wander behaviors on spawned entities, no per-game route loop
112
+ * @capability behavior-tick auto-advance spawned patrol, wander, pursuit and decision graph behaviors with lifecycle control
94
113
  */
95
114
  export declare function advanceBehaviors(ctx: GameContext, dt: number): void;
96
115
  /**
@@ -101,3 +120,5 @@ export declare function advanceBehaviors(ctx: GameContext, dt: number): void;
101
120
  * @capability behavior-control pause/disable/resume/seek/inspect behavior instances per entity
102
121
  */
103
122
  export declare function behaviorControl(ctx: GameContext): BehaviorControl;
123
+ /** @internal Register persistence before boot-time restore without constructing an AI runtime. */
124
+ export declare function installPursuitPersistence(ctx: GameContext, aoiRadius?: number, eligible?: NonNullable<GameDefinition["pursuit"]>["eligible"]): void;
@@ -1,8 +1,15 @@
1
1
  import { createDecisionGraphRuntime } from "../ai/decisionGraph.js";
2
- import { advanceInterestGate, createInterestGateState } from "../ai/interestScheduler.js";
2
+ import { advanceInterestGate, createInterestGateState, interestPhase } from "../ai/interestScheduler.js";
3
+ import { advancePursuit, armPursuit, createPursuitState } from "../ai/pursuit.js";
4
+ import { acquireTarget } from "../ai/targetAcquisition.js";
5
+ import { createThreatTable } from "../ai/threat.js";
3
6
  import { advancePathFollow, createPathFollow, pathFollowSeek, } from "../nav/pathFollow.js";
4
7
  import { resolveWalkerStep } from "../movement/solidObstacles.js";
5
8
  import { perContext } from "../runtime/perContext.js";
9
+ import { notifyAfter } from "../store/changeSignal.js";
10
+ import { visibleEntityIds } from "../runtime/worldProjection.js";
11
+ import { behaviorDataEqual } from "./behaviors.js";
12
+ import { distanceBetween } from "./spatial.js";
6
13
  const DEFAULT_WANDER_SPEED = 1.5;
7
14
  const WANDER_ARRIVAL = 0.6;
8
15
  /** Below this share of its intended step a wanderer counts as blocked and re-rolls its target. */
@@ -19,6 +26,8 @@ export function registerBehaviorActions(id, actions, options = {}) {
19
26
  behaviorActions.delete(id);
20
27
  };
21
28
  }
29
+ // Bind unsaved policy without eagerly constructing the lazy AI runtime before boot-time restore.
30
+ const pursuitPolicies = new WeakMap();
22
31
  function thinkCadence(behavior) {
23
32
  return { wakeRadius: Number.POSITIVE_INFINITY, activeInterval: Math.max(0, behavior.thinkInterval ?? 0) };
24
33
  }
@@ -33,12 +42,20 @@ function decisionGraphOf(entity) {
33
42
  }
34
43
  const runtimeOf = perContext((ctx) => {
35
44
  const nav = new Map();
36
- const refresh = () => {
37
- const live = new Set();
38
- for (const entity of ctx.scene.entity.list()) {
39
- live.add(entity.id);
40
- if (nav.has(entity.id))
41
- continue;
45
+ const descriptors = new Map();
46
+ const refreshId = (id) => {
47
+ const entity = ctx.scene.entity.get(id);
48
+ const descriptor = entity === null ? undefined : patrolOf(entity) ?? wanderOf(entity) ?? decisionGraphOf(entity)
49
+ ?? entity.behaviors.find(behavior => behavior.kind === "pursue");
50
+ if (entity !== null && descriptor !== undefined && nav.has(id) && behaviorDataEqual(descriptors.get(id), descriptor))
51
+ return;
52
+ const previous = nav.get(id);
53
+ nav.delete(id);
54
+ descriptors.delete(id);
55
+ try {
56
+ if (entity === null || descriptor === undefined)
57
+ return;
58
+ descriptors.set(id, descriptor);
42
59
  const patrol = patrolOf(entity);
43
60
  if (patrol !== null) {
44
61
  const config = { waypoints: patrol.waypoints, speed: patrol.speed, loop: patrol.loop };
@@ -51,7 +68,7 @@ const runtimeOf = perContext((ctx) => {
51
68
  reason: null,
52
69
  pausedElapsed: 0,
53
70
  });
54
- continue;
71
+ return;
55
72
  }
56
73
  const wander = wanderOf(entity);
57
74
  if (wander !== null) {
@@ -65,13 +82,13 @@ const runtimeOf = perContext((ctx) => {
65
82
  reason: null,
66
83
  pausedElapsed: 0,
67
84
  });
68
- continue;
85
+ return;
69
86
  }
70
87
  const decisionGraph = decisionGraphOf(entity);
71
88
  if (decisionGraph !== null) {
72
89
  const registered = behaviorActions.get(decisionGraph.actions);
73
90
  if (registered === undefined)
74
- continue;
91
+ return;
75
92
  const cadence = thinkCadence(decisionGraph);
76
93
  nav.set(entity.id, {
77
94
  kind: "decisionGraph",
@@ -89,15 +106,141 @@ const runtimeOf = perContext((ctx) => {
89
106
  pausedElapsed: 0,
90
107
  });
91
108
  }
109
+ const pursuing = entity.behaviors.find((behavior) => behavior.kind === "pursue");
110
+ if (pursuing?.kind === "pursue" && !nav.has(entity.id)) {
111
+ const cadence = { wakeRadius: Infinity, activeInterval: pursuing.thinkInterval ?? .1 };
112
+ const gate = createInterestGateState(cadence, interestPhase(entity.id));
113
+ gate.state = "active";
114
+ nav.set(entity.id, { kind: "pursue", behavior: pursuing,
115
+ home: [...(ctx.scene.entity.spawnPoseOf(entity.id)?.position ?? entity.position)],
116
+ targetId: null, returning: false, pursuit: createPursuitState(),
117
+ gate, cadence, pending: 0,
118
+ status: "active", reason: null, pausedElapsed: 0,
119
+ ...(pursuing.threat === undefined ? {} : { threat: createThreatTable(pursuing.threat) }) });
120
+ }
121
+ }
122
+ finally {
123
+ if (previous?.kind === "decisionGraph")
124
+ previous.runtime.abort({ ctx, entityId: id, dt: 0 }, previous.blackboard);
125
+ }
126
+ };
127
+ const refresh = () => {
128
+ const live = new Set();
129
+ for (const entity of ctx.scene.entity.list()) {
130
+ live.add(entity.id);
131
+ refreshId(entity.id);
92
132
  }
93
- for (const id of nav.keys())
133
+ for (const id of descriptors.keys())
94
134
  if (!live.has(id))
95
- nav.delete(id);
135
+ refreshId(id);
96
136
  };
97
- ctx.scene.entity.subscribeMembership(refresh);
137
+ ctx.scene.entity.subscribeBehaviors(refreshId);
98
138
  refresh();
99
139
  return { nav };
100
140
  });
141
+ function resetPursue(ctx, id, entry) {
142
+ entry.targetId = null;
143
+ entry.returning = false;
144
+ entry.pursuit.attackCooldown = 0;
145
+ entry.pending = 0;
146
+ entry.gate = createInterestGateState(entry.cadence, interestPhase(id));
147
+ entry.gate.state = "active";
148
+ entry.threat?.clear();
149
+ ctx.scene.entity.setTarget(id, null);
150
+ }
151
+ function object(value) {
152
+ return value !== null && typeof value === "object" && !Array.isArray(value);
153
+ }
154
+ function nonnegative(value) {
155
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
156
+ }
157
+ function validPursueSnapshot(value) {
158
+ if (!object(value) || value.kind !== "pursue" || !Array.isArray(value.home) || value.home.length !== 3
159
+ || !value.home.every(n => typeof n === "number" && Number.isFinite(n))
160
+ || !(value.targetId === null || typeof value.targetId === "string")
161
+ || !(value.explicitTargetId === null || typeof value.explicitTargetId === "string") || typeof value.returning !== "boolean"
162
+ || !object(value.pursuit) || !nonnegative(value.pursuit.attackCooldown) || !nonnegative(value.pending)
163
+ || !object(value.gate) || !(value.gate.state === "active" || value.gate.state === "dormant")
164
+ || !nonnegative(value.gate.clock) || !nonnegative(value.gate.awakeHold)
165
+ || !nonnegative(value.gate.phase) || value.gate.phase >= 1)
166
+ return false;
167
+ if (value.threat !== undefined) {
168
+ if (!object(value.threat) || !Array.isArray(value.threat.entries) || !nonnegative(value.threat.forcedRemaining)
169
+ || !(value.threat.forcedSource === null || typeof value.threat.forcedSource === "string"))
170
+ return false;
171
+ const seen = new Set();
172
+ for (const entry of value.threat.entries) {
173
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || seen.has(entry[0])
174
+ || typeof entry[1] !== "number" || !Number.isFinite(entry[1]))
175
+ return false;
176
+ seen.add(entry[0]);
177
+ }
178
+ }
179
+ return true;
180
+ }
181
+ function restorePursue(entry, snapshot) {
182
+ entry.home = [...snapshot.home];
183
+ entry.targetId = snapshot.targetId;
184
+ entry.returning = snapshot.returning;
185
+ entry.pursuit = { ...snapshot.pursuit };
186
+ entry.gate = { ...snapshot.gate };
187
+ entry.pending = snapshot.pending;
188
+ if (entry.threat !== undefined && snapshot.threat !== undefined)
189
+ entry.threat.restore(snapshot.threat);
190
+ else
191
+ entry.threat?.clear();
192
+ }
193
+ function stepPursue(ctx, id, entry, dt) {
194
+ const entity = ctx.scene.entity.get(id);
195
+ if (entity === null)
196
+ return;
197
+ const { behavior, home } = entry;
198
+ const homeDistance = distanceBetween(entity.position, home);
199
+ const policy = pursuitPolicies.get(ctx);
200
+ const eligible = (candidate) => candidate !== id && ctx.scene.entity.get(candidate) !== null
201
+ && ctx.scene.entity.canReceive(candidate, behavior.attack.effect, behavior.attack.amount) === null
202
+ && (policy === undefined || policy(ctx, id, candidate) === true);
203
+ entry.threat?.decay(dt);
204
+ if (!entry.returning) {
205
+ const explicit = ctx.scene.entity.getTarget(id);
206
+ const forced = entry.threat?.forcedTarget() ?? null;
207
+ if (explicit !== null && eligible(explicit))
208
+ entry.targetId = explicit;
209
+ else if (forced !== null && eligible(forced))
210
+ entry.targetId = forced;
211
+ else {
212
+ const nearby = ctx.scene.entity.inRadius(id, behavior.aggroRadius, candidate => ctx.scene.entity.get(candidate)?.role === "player");
213
+ if (entry.targetId !== null && !nearby.includes(entry.targetId))
214
+ nearby.push(entry.targetId);
215
+ const held = entry.targetId;
216
+ entry.targetId = acquireTarget({ candidates: () => nearby, distance: ctx.scene.entity.distance,
217
+ range: (_self, candidate) => candidate === held ? Infinity : behavior.aggroRadius,
218
+ eligible: (_self, candidate) => eligible(candidate), score: (_self, candidate) => entry.threat?.threatOf(candidate) ?? 0,
219
+ tieBreak: (a, b) => (ctx.scene.entity.distance(id, a) ?? Infinity) - (ctx.scene.entity.distance(id, b) ?? Infinity)
220
+ || (a < b ? -1 : a > b ? 1 : 0),
221
+ }, id, held).targetId;
222
+ }
223
+ }
224
+ const targetDistance = entry.targetId === null ? null : ctx.scene.entity.distance(id, entry.targetId);
225
+ const action = advancePursuit(entry.pursuit, dt, targetDistance, behavior.reach, behavior.cooldownMode, homeDistance, behavior.leashRange);
226
+ if (action === "leash" || (targetDistance === null && homeDistance > .1))
227
+ entry.returning = true;
228
+ if (entry.returning) {
229
+ entry.targetId = null;
230
+ ctx.scene.entity.setTarget(id, null);
231
+ ctx.scene.entity.moveTowardCommit(id, home, { speed: behavior.speed, stopDistance: 0, dt, face: true });
232
+ const position = ctx.scene.entity.get(id)?.position;
233
+ if (position !== undefined && distanceBetween(position, home) <= .1)
234
+ resetPursue(ctx, id, entry);
235
+ }
236
+ else if (action === "pursue" && entry.targetId !== null) {
237
+ ctx.scene.entity.moveTowardCommit(id, entry.targetId, { speed: behavior.speed, stopDistance: behavior.reach, dt, face: true });
238
+ }
239
+ else if (action === "attack" && entry.targetId !== null) {
240
+ ctx.scene.entity.effect({ from: id, to: entry.targetId, effect: behavior.attack.effect, via: { amount: behavior.attack.amount } });
241
+ armPursuit(entry.pursuit, behavior.attack.intervalSec);
242
+ }
243
+ }
101
244
  function stepWander(ctx, id, nav, dt) {
102
245
  const entity = ctx.scene.entity.get(id);
103
246
  if (entity === null)
@@ -153,13 +296,13 @@ function posePatrol(ctx, id, nav, dt) {
153
296
  });
154
297
  }
155
298
  /**
156
- * Advance every spawned entity carrying a `patrol` or `wander` {@link BehaviorDescriptor} one tick — the
299
+ * Advance every spawned entity carrying a patrol, wander, pursuit or decision graph behavior one tick — the
157
300
  * engine reads the descriptor, keeps the per-entity nav state itself, and poses the entity, so ambient
158
301
  * traffic and idle NPC routes are register-once (attach the behavior at spawn) instead of a per-game
159
302
  * per-frame `advancePathFollow` + `setPose` loop. Instances that are paused or disabled through
160
303
  * {@link behaviorControl} retain their state and are skipped. The shell/host call this each frame; a game never does.
161
304
  *
162
- * @capability behavior-tick auto-advance patrol/wander behaviors on spawned entities, no per-game route loop
305
+ * @capability behavior-tick auto-advance spawned patrol, wander, pursuit and decision graph behaviors with lifecycle control
163
306
  */
164
307
  export function advanceBehaviors(ctx, dt) {
165
308
  if (dt <= 0)
@@ -167,10 +310,14 @@ export function advanceBehaviors(ctx, dt) {
167
310
  const { nav } = runtimeOf(ctx);
168
311
  if (nav.size === 0)
169
312
  return;
313
+ let pursuitChanged = false;
170
314
  for (const [id, entry] of nav) {
171
315
  if (entry.status !== "active") {
172
- if (entry.status === "paused")
316
+ if (entry.status === "paused") {
173
317
  entry.pausedElapsed += dt;
318
+ if (entry.kind === "pursue")
319
+ pursuitChanged = true;
320
+ }
174
321
  continue;
175
322
  }
176
323
  if (entry.kind === "patrol") {
@@ -180,6 +327,15 @@ export function advanceBehaviors(ctx, dt) {
180
327
  else if (entry.kind === "wander") {
181
328
  stepWander(ctx, id, entry, dt);
182
329
  }
330
+ else if (entry.kind === "pursue") {
331
+ entry.pending += dt;
332
+ pursuitChanged = true;
333
+ if (!advanceInterestGate(entry.gate, entry.cadence, dt, { proximity: 0 }).active)
334
+ continue;
335
+ const elapsed = entry.pending;
336
+ entry.pending = 0;
337
+ stepPursue(ctx, id, entry, elapsed);
338
+ }
183
339
  else {
184
340
  const entity = ctx.scene.entity.get(id);
185
341
  if (entity === null)
@@ -193,6 +349,8 @@ export function advanceBehaviors(ctx, dt) {
193
349
  entry.runtime.tick({ ctx, entityId: id, dt: elapsed }, entry.blackboard, elapsed);
194
350
  }
195
351
  }
352
+ if (pursuitChanged)
353
+ ctx.touch();
196
354
  }
197
355
  /**
198
356
  * Obtain the per-context {@link BehaviorControl} surface for suspending, resuming, seeking, serializing,
@@ -209,6 +367,8 @@ export function behaviorControl(ctx) {
209
367
  return false;
210
368
  entry.status = status;
211
369
  entry.reason = reason;
370
+ if (entry.kind === "pursue")
371
+ ctx.touch();
212
372
  return true;
213
373
  };
214
374
  return {
@@ -221,6 +381,8 @@ export function behaviorControl(ctx) {
221
381
  entry.status = "paused";
222
382
  entry.reason = reason ?? null;
223
383
  entry.pausedElapsed = 0;
384
+ if (entry.kind === "pursue")
385
+ ctx.touch();
224
386
  return true;
225
387
  },
226
388
  resume: (id, policy = "freeze") => {
@@ -235,6 +397,8 @@ export function behaviorControl(ctx) {
235
397
  entry.status = "active";
236
398
  entry.reason = null;
237
399
  entry.pausedElapsed = 0;
400
+ if (entry.kind === "pursue")
401
+ ctx.touch();
238
402
  return true;
239
403
  },
240
404
  disable: (id, reason) => setStatus(id, "disabled", reason ?? null),
@@ -245,6 +409,8 @@ export function behaviorControl(ctx) {
245
409
  entry.status = "active";
246
410
  entry.reason = null;
247
411
  entry.pausedElapsed = 0;
412
+ if (entry.kind === "pursue")
413
+ ctx.touch();
248
414
  return true;
249
415
  },
250
416
  seek: (id, progress) => {
@@ -262,6 +428,11 @@ export function behaviorControl(ctx) {
262
428
  return { kind: "patrol", state: { ...entry.state } };
263
429
  if (entry.kind === "wander")
264
430
  return { kind: "wander", origin: [...entry.origin], target: entry.target === null ? null : [...entry.target] };
431
+ if (entry.kind === "pursue")
432
+ return { kind: "pursue", home: [...entry.home], targetId: entry.targetId,
433
+ explicitTargetId: ctx.scene.entity.getTarget(id),
434
+ returning: entry.returning, pursuit: { ...entry.pursuit }, gate: { ...entry.gate }, pending: entry.pending,
435
+ ...(entry.threat === undefined ? {} : { threat: entry.threat.snapshot() }) };
265
436
  return { kind: "decisionGraph", graph: entry.runtime.snapshot(), gate: { ...entry.gate }, blackboard: { ...entry.blackboard }, pending: entry.pending };
266
437
  },
267
438
  restore: (id, snapshot) => {
@@ -284,6 +455,14 @@ export function behaviorControl(ctx) {
284
455
  entry.target = snapshot.target === null ? null : [...snapshot.target];
285
456
  return true;
286
457
  }
458
+ if (entry.kind === "pursue" && snapshot.kind === "pursue") {
459
+ if (!validPursueSnapshot(snapshot))
460
+ return false;
461
+ restorePursue(entry, snapshot);
462
+ ctx.scene.entity.setTarget(id, snapshot.explicitTargetId);
463
+ ctx.touch();
464
+ return true;
465
+ }
287
466
  return false;
288
467
  },
289
468
  inspect: (id) => {
@@ -302,5 +481,73 @@ export function behaviorControl(ctx) {
302
481
  const entry = nav.get(id);
303
482
  return entry?.kind === "decisionGraph" ? entry.blackboard : null;
304
483
  },
484
+ threat: (id) => {
485
+ const entry = nav.get(id);
486
+ if (entry?.kind !== "pursue" || entry.threat === undefined)
487
+ return null;
488
+ return entry.threatControl ??= notifyAfter(entry.threat, ["add", "set", "taunt", "remove", "clear", "restore", "decay"], ctx.touch);
489
+ },
490
+ reset: (id) => {
491
+ const entry = nav.get(id);
492
+ if (entry?.kind !== "pursue")
493
+ return false;
494
+ resetPursue(ctx, id, entry);
495
+ ctx.touch();
496
+ return true;
497
+ },
305
498
  };
306
499
  }
500
+ /** @internal Register persistence before boot-time restore without constructing an AI runtime. */
501
+ export function installPursuitPersistence(ctx, aoiRadius, eligible) {
502
+ if (eligible !== undefined)
503
+ pursuitPolicies.set(ctx, eligible);
504
+ ctx.game.registerReplicate?.({
505
+ key: "pursuitBehaviors",
506
+ snapshot() {
507
+ const control = behaviorControl(ctx);
508
+ const instances = [];
509
+ for (const [id, entry] of runtimeOf(ctx).nav) {
510
+ if (entry.kind !== "pursue")
511
+ continue;
512
+ instances.push({ id, snapshot: control.serialize(id),
513
+ status: entry.status, reason: entry.reason, pausedElapsed: entry.pausedElapsed });
514
+ }
515
+ return { version: 1, instances };
516
+ },
517
+ decode(raw) {
518
+ if (!object(raw) || raw.version !== 1 || !Array.isArray(raw.instances))
519
+ return null;
520
+ const seen = new Set();
521
+ for (const value of raw.instances) {
522
+ if (!object(value) || typeof value.id !== "string" || seen.has(value.id)
523
+ || !validPursueSnapshot(value.snapshot) || !(value.status === "active" || value.status === "paused" || value.status === "disabled")
524
+ || !(value.reason === null || typeof value.reason === "string") || !nonnegative(value.pausedElapsed)
525
+ || !ctx.scene.entity.get(value.id)?.behaviors.some(behavior => behavior.kind === "pursue"))
526
+ return null;
527
+ seen.add(value.id);
528
+ }
529
+ return structuredClone(raw);
530
+ },
531
+ hydrate(data) {
532
+ const runtime = runtimeOf(ctx);
533
+ for (const record of data.instances) {
534
+ const entry = runtime.nav.get(record.id);
535
+ if (entry?.kind !== "pursue")
536
+ continue;
537
+ restorePursue(entry, record.snapshot);
538
+ entry.status = record.status;
539
+ entry.reason = record.reason;
540
+ entry.pausedElapsed = record.pausedElapsed;
541
+ }
542
+ for (const record of data.instances)
543
+ ctx.scene.entity.setTarget(record.id, record.snapshot.explicitTargetId);
544
+ ctx.touch();
545
+ },
546
+ ...(aoiRadius === undefined ? {} : {
547
+ project(data, viewer, world) {
548
+ const visible = visibleEntityIds((world.entities ?? []), viewer, aoiRadius);
549
+ return { ...data, instances: data.instances.filter(record => visible.has(record.id)) };
550
+ },
551
+ }),
552
+ });
553
+ }
@@ -1,6 +1,29 @@
1
1
  import { type ProximityPrompt } from "../interaction/proximityPrompt.js";
2
2
  import type { PathProgress, Waypoint } from "../nav/pathFollow.js";
3
3
  import type { Blackboard, DecisionGraph } from "../ai/decisionGraph.js";
4
+ import type { CooldownMode } from "../ai/pursuit.js";
5
+ import type { ThreatTableConfig } from "../ai/threat.js";
6
+ /** Scheduled chase/attack behavior; the default declares hostility toward player-role entities.
7
+ * GameDefinition.pursuit.eligible can further restrict every target source using the live world.
8
+ * Explicit targets and optional threat override nearest-player acquisition, not eligibility.
9
+ */
10
+ export interface PursueBehavior {
11
+ kind: "pursue";
12
+ aggroRadius: number;
13
+ reach: number;
14
+ leashRange: number;
15
+ speed: number;
16
+ attack: {
17
+ effect: string;
18
+ amount: number;
19
+ intervalSec: number;
20
+ };
21
+ /** Seconds between acquisition/movement/attack ticks; default .1. Zero advances every frame. */
22
+ thinkInterval?: number;
23
+ cooldownMode?: CooldownMode;
24
+ /** Opt into a per-instance table available through `behaviorControl(ctx).threat(id)`. */
25
+ threat?: ThreatTableConfig;
26
+ }
4
27
  export interface WanderBehavior {
5
28
  kind: "wander";
6
29
  radius: number;
@@ -38,7 +61,13 @@ export interface DecisionGraphBehavior {
38
61
  /** Seconds between graph ticks (`0` = every frame). Siblings are staggered; actions receive the elapsed time. */
39
62
  thinkInterval?: number;
40
63
  }
41
- export type BehaviorDescriptor = WanderBehavior | PatrolBehavior | PromptableBehavior | PlayerBehavior | DecisionGraphBehavior;
64
+ export type BehaviorDescriptor = WanderBehavior | PatrolBehavior | PromptableBehavior | PlayerBehavior | PursueBehavior | DecisionGraphBehavior;
65
+ /** @internal Compare declarative behavior data at replacement boundaries, independent of object key order. */
66
+ export declare function behaviorDataEqual(a: unknown, b: unknown): boolean;
67
+ /** Attach aggro, collision-aware chase, cooldown attack and latched return-to-spawn as data.
68
+ * @capability pursue-behavior scheduled pursuit with bounded target acquisition and saved cooldown/leash state
69
+ */
70
+ export declare function pursue(options: Omit<PursueBehavior, "kind">): PursueBehavior;
42
71
  export declare function wander({ radius }: {
43
72
  radius: number;
44
73
  }): WanderBehavior;
@@ -1,4 +1,33 @@
1
1
  import { command, keybind, proximityPrompt, } from "../interaction/proximityPrompt.js";
2
+ /** @internal Compare declarative behavior data at replacement boundaries, independent of object key order. */
3
+ export function behaviorDataEqual(a, b) {
4
+ if (Object.is(a, b))
5
+ return true;
6
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object")
7
+ return false;
8
+ if (Array.isArray(a) || Array.isArray(b))
9
+ return Array.isArray(a) && Array.isArray(b)
10
+ && a.length === b.length && a.every((value, index) => behaviorDataEqual(value, b[index]));
11
+ const left = a, right = b;
12
+ const keys = Object.keys(left).filter(key => left[key] !== undefined);
13
+ return keys.length === Object.keys(right).filter(key => right[key] !== undefined).length
14
+ && keys.every(key => Object.hasOwn(right, key) && behaviorDataEqual(left[key], right[key]));
15
+ }
16
+ /** Attach aggro, collision-aware chase, cooldown attack and latched return-to-spawn as data.
17
+ * @capability pursue-behavior scheduled pursuit with bounded target acquisition and saved cooldown/leash state
18
+ */
19
+ export function pursue(options) {
20
+ for (const [name, value] of Object.entries({ aggroRadius: options.aggroRadius, reach: options.reach,
21
+ leashRange: options.leashRange, speed: options.speed, intervalSec: options.attack.intervalSec,
22
+ thinkInterval: options.thinkInterval ?? .1 })) {
23
+ if (!Number.isFinite(value) || value < 0)
24
+ throw new Error(`pursue: ${name} must be finite and nonnegative`);
25
+ }
26
+ if (!Number.isFinite(options.attack.amount))
27
+ throw new Error("pursue: attack amount must be finite");
28
+ return { ...options, kind: "pursue", attack: { ...options.attack },
29
+ ...(options.threat === undefined ? {} : { threat: { ...options.threat } }) };
30
+ }
2
31
  export function wander({ radius }) {
3
32
  return { kind: "wander", radius };
4
33
  }
@@ -1,4 +1,4 @@
1
- import type { BehaviorDescriptor } from "./behaviors.js";
1
+ import { type BehaviorDescriptor } from "./behaviors.js";
2
2
  export type EntityRole = "player" | "npc" | "prop";
3
3
  export interface EntityMovement {
4
4
  walkSpeed?: number;
@@ -17,7 +17,7 @@ export interface SceneEntity<TMeta = unknown> {
17
17
  rotationY: number;
18
18
  rotationX: number;
19
19
  rotationZ: number;
20
- /** World units per second, derived from consecutive setPose calls that carry `dt`. Zero until the entity moves under a dt. */
20
+ /** World units per second, derived by `setPose` with `dt` or explicitly replaced by `setVelocity`. Zero until set. */
21
21
  velocity: EntityPosition;
22
22
  role: EntityRole;
23
23
  movement: EntityMovement;
@@ -97,11 +97,14 @@ export interface EntityBlackboard {
97
97
  /** Milliseconds until the armed cooldown elapses; 0 when ready. */
98
98
  remaining(id: string, key: string, nowMs: number): number;
99
99
  }
100
+ /** Entity state, pose writes, keyed behavior notifications and snapshot/hydration under stable instance IDs. */
100
101
  export interface EntityStore<TMeta = unknown> {
101
102
  spawn(name: string, options?: SpawnOptions<TMeta>): string;
102
103
  despawn(id: string): boolean;
103
104
  update(id: string, patch: EntityUpdatePatch<TMeta>): boolean;
104
105
  setPose(id: string, pose: EntityPose): boolean;
106
+ /** Replace velocity without moving the entity. Copies a finite three-number tuple; invalid input or a missing id returns false unchanged. */
107
+ setVelocity(id: string, velocity: EntityPosition): boolean;
105
108
  /** Register a constraint applied inside every `setPose` for this entity — the self-driven sibling of the shell's `beforeCommit` (#282.9): nav clamps, corridor walls, arena bounds without wrapping every call site. `null` clears; despawn clears automatically. */
106
109
  setPoseConstraint(id: string, constraint: PoseConstraint | null): void;
107
110
  get(id: string): SceneEntity<TMeta> | null;
@@ -112,6 +115,8 @@ export interface EntityStore<TMeta = unknown> {
112
115
  subscribe(listener: () => void): () => void;
113
116
  /** Notified only when the entity set changes (spawn/despawn/hydrate), never on a pose or field update — the render tree re-reconciles on membership, individual markers read live poses imperatively. */
114
117
  subscribeMembership(listener: () => void): () => void;
118
+ /** Changed behavior data or removal, keyed by id and notified before ordinary subscribers. Equal-data hydration and pose writes do not fire. */
119
+ subscribeBehaviors(listener: (id: string) => void): () => void;
115
120
  snapshot(): readonly SceneEntity<TMeta>[];
116
121
  /**
117
122
  * Apply a `snapshot()` (e.g. from an authoritative host) to this store — the counterpart of `snapshot()`
@@ -123,4 +128,10 @@ export interface EntityStore<TMeta = unknown> {
123
128
  resetToSpawn(id: string): boolean;
124
129
  blackboard: EntityBlackboard;
125
130
  }
126
- export declare function createEntityStore<TMeta = unknown>(): EntityStore<TMeta>;
131
+ /** Commit hook for a derived index; runs after the write and before existing subscribers.
132
+ * `undefined` denotes a whole-store replacement. Value overwrites keep membership false.
133
+ */
134
+ export interface EntityStoreOptions {
135
+ onChange?: (instanceId: string | undefined, membershipChanged: boolean) => void;
136
+ }
137
+ export declare function createEntityStore<TMeta = unknown>(options?: EntityStoreOptions): EntityStore<TMeta>;