@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
@@ -1,4 +1,5 @@
1
1
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
2
+ import { behaviorDataEqual } from "./behaviors.js";
2
3
  /**
3
4
  * Narrow `entity.meta` with a type guard — prefer this over `entity.meta as T` so failed shapes return `null`
4
5
  * instead of lying to the type checker.
@@ -26,12 +27,29 @@ function toEntityPosition(position) {
26
27
  return [position.x, position.y, position.z];
27
28
  return position;
28
29
  }
29
- export function createEntityStore() {
30
- const store = createObservableKeyedStore();
30
+ export function createEntityStore(options = {}) {
31
+ const behaviorListeners = new Set();
32
+ const behaviorChanges = new Set();
33
+ const store = createObservableKeyedStore(undefined, (id, membershipChanged) => {
34
+ options.onChange?.(id, membershipChanged);
35
+ if (id !== undefined && behaviorChanges.delete(id))
36
+ for (const listener of behaviorListeners)
37
+ listener(id);
38
+ });
31
39
  const spawnPoses = new Map();
32
40
  const constraints = new Map();
33
41
  const blackboards = new Map();
34
42
  let nextCounter = 1;
43
+ function writeEntity(entity, previous = store.get(entity.id)?.behaviors) {
44
+ if (!behaviorDataEqual(previous ?? [], entity.behaviors))
45
+ behaviorChanges.add(entity.id);
46
+ store.set(entity.id, entity);
47
+ }
48
+ function deleteEntity(id) {
49
+ if ((store.get(id)?.behaviors.length ?? 0) > 0)
50
+ behaviorChanges.add(id);
51
+ store.delete(id);
52
+ }
35
53
  const blackboard = {
36
54
  get: (id, key) => blackboards.get(id)?.get(key),
37
55
  set(id, key, value) {
@@ -80,7 +98,8 @@ export function createEntityStore() {
80
98
  const id = options.id ?? generateId();
81
99
  const position = toEntityPosition(options.position);
82
100
  const rotationY = options.rotationY ?? 0;
83
- store.set(id, {
101
+ spawnPoses.set(id, { position, rotationY });
102
+ writeEntity({
84
103
  id,
85
104
  name,
86
105
  position,
@@ -94,12 +113,11 @@ export function createEntityStore() {
94
113
  ...(options.hidden === undefined ? {} : { hidden: options.hidden }),
95
114
  meta: options.meta,
96
115
  });
97
- spawnPoses.set(id, { position, rotationY });
98
116
  return id;
99
117
  },
100
118
  despawn(id) {
101
119
  const existed = store.has(id);
102
- store.delete(id);
120
+ deleteEntity(id);
103
121
  spawnPoses.delete(id);
104
122
  constraints.delete(id);
105
123
  blackboards.delete(id);
@@ -109,6 +127,7 @@ export function createEntityStore() {
109
127
  const current = store.get(id);
110
128
  if (!current)
111
129
  return false;
130
+ const previousBehaviors = current.behaviors;
112
131
  if (patch.position !== undefined)
113
132
  current.position = toEntityPosition(patch.position);
114
133
  if (patch.name !== undefined)
@@ -129,7 +148,10 @@ export function createEntityStore() {
129
148
  current.hidden = patch.hidden;
130
149
  if (patch.meta !== undefined)
131
150
  current.meta = patch.meta;
132
- store.set(id, current);
151
+ if (patch.behaviors === undefined)
152
+ store.set(id, current);
153
+ else
154
+ writeEntity(current, previousBehaviors);
133
155
  return true;
134
156
  },
135
157
  setPoseConstraint(id, constraint) {
@@ -173,6 +195,15 @@ export function createEntityStore() {
173
195
  store.set(id, current);
174
196
  return true;
175
197
  },
198
+ setVelocity(id, velocity) {
199
+ const current = store.get(id);
200
+ if (current === undefined || !Array.isArray(velocity) || velocity.length !== 3 ||
201
+ !velocity.every((component) => typeof component === "number" && Number.isFinite(component)))
202
+ return false;
203
+ current.velocity = [velocity[0], velocity[1], velocity[2]];
204
+ store.set(id, current);
205
+ return true;
206
+ },
176
207
  get(id) {
177
208
  return store.get(id) ?? null;
178
209
  },
@@ -184,7 +215,7 @@ export function createEntityStore() {
184
215
  },
185
216
  clear() {
186
217
  for (const entity of store.arraySnapshot()) {
187
- store.delete(entity.id);
218
+ deleteEntity(entity.id);
188
219
  }
189
220
  constraints.clear();
190
221
  blackboards.clear();
@@ -195,6 +226,10 @@ export function createEntityStore() {
195
226
  subscribeMembership(listener) {
196
227
  return store.subscribeMembership(listener);
197
228
  },
229
+ subscribeBehaviors(listener) {
230
+ behaviorListeners.add(listener);
231
+ return () => { behaviorListeners.delete(listener); };
232
+ },
198
233
  snapshot() {
199
234
  return store.arraySnapshot();
200
235
  },
@@ -202,14 +237,14 @@ export function createEntityStore() {
202
237
  const incoming = new Set(entities.map((entity) => entity.id));
203
238
  for (const current of store.arraySnapshot()) {
204
239
  if (!incoming.has(current.id)) {
205
- store.delete(current.id);
240
+ deleteEntity(current.id);
206
241
  spawnPoses.delete(current.id);
207
242
  constraints.delete(current.id);
208
243
  blackboards.delete(current.id);
209
244
  }
210
245
  }
211
246
  for (const entity of entities) {
212
- store.set(entity.id, {
247
+ writeEntity({
213
248
  id: entity.id,
214
249
  name: entity.name,
215
250
  position: entity.position,
@@ -220,6 +255,7 @@ export function createEntityStore() {
220
255
  role: entity.role,
221
256
  movement: entity.movement,
222
257
  behaviors: entity.behaviors,
258
+ ...(entity.hidden === undefined ? {} : { hidden: entity.hidden }),
223
259
  meta: entity.meta,
224
260
  });
225
261
  if (!spawnPoses.has(entity.id)) {
@@ -22,6 +22,10 @@ export interface Occupant {
22
22
  riderId: string;
23
23
  seatId: string;
24
24
  }
25
+ /** Occupancy only; register the world's rideables before restoring. */
26
+ export interface MountedRider extends SeatRef {
27
+ riderId: string;
28
+ }
25
29
  export type MountResult = {
26
30
  ok: true;
27
31
  seat: MountSeat;
@@ -58,5 +62,10 @@ export declare class MountController {
58
62
  * (they ride but do not steer), and the rider themselves when not mounted at all.
59
63
  */
60
64
  driveTarget(riderId: string): string | null;
65
+ snapshot(): MountedRider[];
66
+ /** Replace occupancy atomically. Invalid, unknown, or duplicate claims leave it unchanged. */
67
+ restore(next: readonly MountedRider[]): void;
68
+ /** Release every rider, preserving authored registrations. */
69
+ reset(): void;
61
70
  }
62
71
  export declare function createMountController(): MountController;
@@ -101,6 +101,41 @@ export class MountController {
101
101
  const seat = this.mounts.get(ref.mountId)?.seats.find((s) => s.id === ref.seatId);
102
102
  return seat?.control === true ? ref.mountId : null;
103
103
  }
104
+ snapshot() {
105
+ return [...this.seatOfRider].map(([riderId, ref]) => ({ riderId, ...ref }));
106
+ }
107
+ /** Replace occupancy atomically. Invalid, unknown, or duplicate claims leave it unchanged. */
108
+ restore(next) {
109
+ const riders = new Set();
110
+ const claims = new Map();
111
+ if (!Array.isArray(next))
112
+ throw new Error("mount occupancy must be an array");
113
+ for (const row of next) {
114
+ if (row === null || typeof row !== "object" || typeof row.riderId !== "string" || row.riderId.length === 0 ||
115
+ typeof row.mountId !== "string" || typeof row.seatId !== "string" || riders.has(row.riderId)) {
116
+ throw new Error("invalid mount occupant");
117
+ }
118
+ const reg = this.mounts.get(row.mountId);
119
+ const occupied = claims.get(row.mountId) ?? new Set();
120
+ if (reg === undefined || !reg.seats.some((seat) => seat.id === row.seatId) || occupied.has(row.seatId)) {
121
+ throw new Error("invalid mount seat claim");
122
+ }
123
+ riders.add(row.riderId);
124
+ occupied.add(row.seatId);
125
+ claims.set(row.mountId, occupied);
126
+ }
127
+ this.reset();
128
+ for (const { riderId, mountId, seatId } of next) {
129
+ this.mounts.get(mountId).bySeat.set(seatId, riderId);
130
+ this.seatOfRider.set(riderId, { mountId, seatId });
131
+ }
132
+ }
133
+ /** Release every rider, preserving authored registrations. */
134
+ reset() {
135
+ this.seatOfRider.clear();
136
+ for (const reg of this.mounts.values())
137
+ reg.bySeat.clear();
138
+ }
104
139
  }
105
140
  export function createMountController() {
106
141
  return new MountController();
@@ -25,6 +25,8 @@ export interface Possession {
25
25
  own(userId: string, entityId: string): void;
26
26
  disown(userId: string, entityId: string): void;
27
27
  owns(userId: string, entityId: string): boolean;
28
+ /** Unique owner of an explicitly owned pawn, or undefined when absent or shared. Does not create ownership. */
29
+ ownerOf(entityId: string): string | undefined;
28
30
  listOwned(userId: string): readonly string[];
29
31
  active(userId: string): string;
30
32
  possess(userId: string, entityId: string): {
@@ -1,28 +1,44 @@
1
1
  export function createPossession(deps) {
2
2
  const owned = new Map();
3
3
  const activeByUser = new Map();
4
+ const ownersByEntity = new Map();
5
+ function addOwner(userId, entityId) {
6
+ const owners = ownersByEntity.get(entityId) ?? new Set();
7
+ owners.add(userId);
8
+ ownersByEntity.set(entityId, owners);
9
+ }
4
10
  function ownedSet(userId) {
5
11
  let set = owned.get(userId);
6
12
  if (set === undefined) {
7
13
  set = new Set([userId]);
8
14
  owned.set(userId, set);
15
+ addOwner(userId, userId);
9
16
  }
10
17
  return set;
11
18
  }
12
19
  return {
13
20
  own(userId, entityId) {
14
21
  ownedSet(userId).add(entityId);
22
+ addOwner(userId, entityId);
15
23
  },
16
24
  disown(userId, entityId) {
17
25
  if (entityId === userId)
18
26
  return;
19
27
  ownedSet(userId).delete(entityId);
28
+ const owners = ownersByEntity.get(entityId);
29
+ owners?.delete(userId);
30
+ if (owners?.size === 0)
31
+ ownersByEntity.delete(entityId);
20
32
  if (activeByUser.get(userId) === entityId)
21
33
  activeByUser.delete(userId);
22
34
  },
23
35
  owns(userId, entityId) {
24
36
  return ownedSet(userId).has(entityId);
25
37
  },
38
+ ownerOf(entityId) {
39
+ const owners = ownersByEntity.get(entityId);
40
+ return owners?.size === 1 ? owners.values().next().value : undefined;
41
+ },
26
42
  listOwned(userId) {
27
43
  return Array.from(ownedSet(userId));
28
44
  },
@@ -55,8 +71,12 @@ export function createPossession(deps) {
55
71
  },
56
72
  hydrateAll(state) {
57
73
  owned.clear();
58
- for (const [userId, ids] of Object.entries(state.owned))
74
+ ownersByEntity.clear();
75
+ for (const [userId, ids] of Object.entries(state.owned)) {
59
76
  owned.set(userId, new Set(ids));
77
+ for (const entityId of ids)
78
+ addOwner(userId, entityId);
79
+ }
60
80
  activeByUser.clear();
61
81
  for (const [userId, entityId] of Object.entries(state.active))
62
82
  activeByUser.set(userId, entityId);
@@ -1,6 +1,7 @@
1
1
  import type { EntityPosition } from "./entityStore.js";
2
2
  import { type ColliderPurpose, type EntityColliderSet } from "./colliders.js";
3
3
  import type { SceneObject } from "./objectStore.js";
4
+ import type { WorldSolids } from "../world/worldSolids.js";
4
5
  export type SceneRaycastTargetKind = "entity" | "object" | "terrain" | "wall";
5
6
  export interface SceneRaycastHit {
6
7
  targetKind: SceneRaycastTargetKind;
@@ -18,11 +19,15 @@ export interface SceneRaycastFilter {
18
19
  entities?: boolean;
19
20
  objects?: boolean;
20
21
  terrain?: boolean;
22
+ /** Wall segments and indexed world solids. */
21
23
  walls?: boolean;
22
24
  }
23
25
  export interface SceneRaycastInput {
26
+ /** Finite world coordinates. */
24
27
  origin: EntityPosition;
28
+ /** Finite direction and magnitude, normalized by the query. Zero direction returns no hits. */
25
29
  direction: EntityPosition;
30
+ /** Finite, nonnegative segment length in world units. */
26
31
  maxDistance: number;
27
32
  excludeInstanceIds?: ReadonlySet<string> | readonly string[];
28
33
  filter?: SceneRaycastFilter;
@@ -68,6 +73,8 @@ export interface SceneRaycastDeps {
68
73
  objects?: SceneObjectQuerySource;
69
74
  terrain?: TerrainRaycastSource;
70
75
  walls?: readonly WallSegment[];
76
+ /** Indexed geometry returns physical, non-damage wall hits with instance id `world-solid`. */
77
+ solids?: Pick<WorldSolids, "inBox" | "inRay">;
71
78
  }
72
79
  export interface SceneRaycastApi {
73
80
  raycast(input: SceneRaycastInput): SceneRaycastHit | null;
@@ -127,6 +127,20 @@ function wallAabb(wall) {
127
127
  center: [(ax + bx) / 2, yCenter, (az + bz) / 2],
128
128
  };
129
129
  }
130
+ function rayHitsWorldSolid(solid, origin, direction, maxDistance) {
131
+ const cos = Math.cos(solid.rotationY ?? 0);
132
+ const sin = Math.sin(solid.rotationY ?? 0);
133
+ const x = origin[0] - solid.center[0];
134
+ const z = origin[2] - solid.center[2];
135
+ const [hx, hy, hz] = solid.halfExtents;
136
+ const hit = intersectAabb([x * cos - z * sin, origin[1] - solid.center[1], x * sin + z * cos], [direction[0] * cos - direction[2] * sin, direction[1], direction[0] * sin + direction[2] * cos], [-hx, -hy, -hz], [hx, hy, hz], maxDistance);
137
+ if (hit === null)
138
+ return null;
139
+ return {
140
+ distance: hit.distance,
141
+ normal: [hit.normal[0] * cos + hit.normal[2] * sin, hit.normal[1], -hit.normal[0] * sin + hit.normal[2] * cos],
142
+ };
143
+ }
130
144
  function rayHitsTerrain(origin, direction, maxDistance, terrain) {
131
145
  const steps = Math.max(8, Math.ceil(maxDistance * 2));
132
146
  const step = maxDistance / steps;
@@ -208,6 +222,11 @@ function candidateObjects(objects, origin, direction, maxDistance) {
208
222
  return objects.list();
209
223
  }
210
224
  function gatherHits(deps, input) {
225
+ if (!input.origin.every(Number.isFinite) || !input.direction.every(Number.isFinite) || !Number.isFinite(input.maxDistance) || input.maxDistance < 0) {
226
+ throw new RangeError("Scene rays require finite coordinates, direction and nonnegative distance");
227
+ }
228
+ if (!Number.isFinite(Math.hypot(...input.direction)))
229
+ throw new RangeError("Scene ray direction length must be finite");
211
230
  const direction = normalizeDirection(input.direction);
212
231
  if (direction[0] === 0 && direction[1] === 0 && direction[2] === 0)
213
232
  return [];
@@ -258,6 +277,42 @@ function gatherHits(deps, input) {
258
277
  });
259
278
  }
260
279
  }
280
+ if (enabled(input.filter, "walls") && deps.solids !== undefined && !excluded(input.excludeInstanceIds, "world-solid")) {
281
+ let candidates;
282
+ if (deps.solids.inRay !== undefined) {
283
+ candidates = deps.solids.inRay(origin, direction, maxDistance);
284
+ }
285
+ else {
286
+ const end = [
287
+ origin[0] + direction[0] * maxDistance,
288
+ origin[1] + direction[1] * maxDistance,
289
+ origin[2] + direction[2] * maxDistance,
290
+ ];
291
+ const min = [Math.min(origin[0], end[0]), Math.min(origin[1], end[1]), Math.min(origin[2], end[2])];
292
+ const max = [Math.max(origin[0], end[0]), Math.max(origin[1], end[1]), Math.max(origin[2], end[2])];
293
+ candidates = deps.solids.inBox(min, max);
294
+ }
295
+ for (const solid of candidates) {
296
+ const hit = rayHitsWorldSolid(solid, origin, direction, maxDistance);
297
+ if (hit === null)
298
+ continue;
299
+ hits.push({
300
+ targetKind: "wall",
301
+ instanceId: "world-solid",
302
+ colliderName: "solid",
303
+ purpose: "physical",
304
+ damageEligible: false,
305
+ blocks: true,
306
+ distance: hit.distance,
307
+ normal: hit.normal,
308
+ point: [
309
+ origin[0] + direction[0] * hit.distance,
310
+ origin[1] + direction[1] * hit.distance,
311
+ origin[2] + direction[2] * hit.distance,
312
+ ],
313
+ });
314
+ }
315
+ }
261
316
  if (enabled(input.filter, "terrain") && deps.terrain !== undefined) {
262
317
  const terrainHit = rayHitsTerrain(origin, direction, maxDistance, deps.terrain);
263
318
  if (terrainHit !== null)
@@ -38,11 +38,20 @@ export interface SpatialApiOptions {
38
38
  /**
39
39
  * Broadphase for `inRadius`/`queryArc`. Defaults to `{ cellSize: 8 }`. Pass `false` to force a
40
40
  * linear scan. When enabled, the index rebuilds lazily on first use after `invalidate()` or when
41
- * `getVersion()` advances — call `invalidate()` after moves unless a version source is wired.
41
+ * `getVersion()` advances — call `updateEntity(id)` after each committed move, or `invalidate()`
42
+ * after an unreported batch, unless a version source is wired.
42
43
  */
43
44
  grid?: SpatialGridOptions | false;
44
45
  /** When this number changes between queries, the grid rebuilds without an explicit `invalidate()`. */
45
46
  getVersion?: () => number;
47
+ /** Opt in only when every candidate add/remove/position change calls `updateEntity(id)` or
48
+ * `invalidate()`. Avoids scanning all candidate IDs on warm queries. Initially unresolved IDs
49
+ * still cost one position lookup per query until updated or invalidated. Default false retains
50
+ * discovery of new candidates without notification. If positions also resolve outside the
51
+ * candidate set, pass its explicit presence flag to `updateEntity`. Rebuilds are deferred to
52
+ * the first query.
53
+ */
54
+ incremental?: boolean;
46
55
  /**
47
56
  * Slide a horizontal step against the scene's solid geometry, returning the step actually allowed.
48
57
  * Wired by the runtime to the same blocking-collider query the player resolver reads, so a chaser
@@ -61,6 +70,16 @@ export interface SpatialApi {
61
70
  queryArc(options: QueryArcOptions): string[];
62
71
  moveToward(instanceId: string, target: EntityPosition | string, options: MoveTowardOptions): EntityPosition | null;
63
72
  invalidate(): void;
73
+ /** Notify after committing a candidate's position or membership. Pass `false` to remove an ID
74
+ * even if its position still resolves, or `true` for a present candidate with a late position.
75
+ * Omitted presence asserts membership when the position resolves, otherwise removes the entry;
76
+ * subset candidate sets with a broader position resolver must pass explicit presence.
77
+ * Preserves candidate order; does not build an invalidated/cold index or enumerate candidates.
78
+ */
79
+ updateEntity(instanceId: string, candidatePresent?: boolean): void;
64
80
  }
65
81
  export declare function distanceBetween(a: EntityPosition, b: EntityPosition): number;
82
+ /** Spatial queries with an optional notified incremental broadphase.
83
+ * @capability spatial-incremental Update one entity's spatial cell after a committed write without scanning the population
84
+ */
66
85
  export declare function createSpatialApi(options: SpatialApiOptions): SpatialApi;
@@ -22,6 +22,10 @@ function packCell(cx, cz) {
22
22
  return ((cx + CELL_KEY_OFFSET) << 16) | ((cz + CELL_KEY_OFFSET) & 0xffff);
23
23
  }
24
24
  const DEFAULT_CELL_SIZE = 8;
25
+ const MEMBERSHIP_REBUILD_THRESHOLD = 256;
26
+ /** Spatial queries with an optional notified incremental broadphase.
27
+ * @capability spatial-incremental Update one entity's spatial cell after a committed write without scanning the population
28
+ */
25
29
  export function createSpatialApi(options) {
26
30
  const { resolvePosition, candidates, occluder, getVersion, resolveStep } = options;
27
31
  const gridConfig = options.grid === false ? undefined : (options.grid ?? { cellSize: DEFAULT_CELL_SIZE });
@@ -29,10 +33,14 @@ export function createSpatialApi(options) {
29
33
  let gridIndex = null;
30
34
  let gridDirty = true;
31
35
  let lastVersion;
36
+ let membershipChanges = 0;
32
37
  function resolveTarget(target) {
33
38
  return typeof target === "string" ? resolvePosition(target) : target;
34
39
  }
35
40
  function takeBucket(index) {
41
+ const recycled = index.freeBuckets.pop();
42
+ if (recycled !== undefined)
43
+ return recycled;
36
44
  if (index.poolCursor < index.bucketPool.length) {
37
45
  const bucket = index.bucketPool[index.poolCursor];
38
46
  index.poolCursor += 1;
@@ -52,8 +60,10 @@ export function createSpatialApi(options) {
52
60
  gridDirty = true;
53
61
  lastVersion = version;
54
62
  }
55
- if (!gridDirty && gridIndex !== null)
63
+ if (!gridDirty && gridIndex !== null) {
64
+ membershipChanges = 0;
56
65
  return gridIndex;
66
+ }
57
67
  const previous = gridIndex;
58
68
  const cells = previous?.cells ?? new Map();
59
69
  cells.clear();
@@ -64,13 +74,27 @@ export function createSpatialApi(options) {
64
74
  indexed,
65
75
  bucketPool: previous?.bucketPool ?? [],
66
76
  poolCursor: 0,
77
+ locations: previous?.locations ?? new Map(),
78
+ order: previous?.order ?? new Map(),
79
+ nextOrder: 0,
80
+ unresolved: previous?.unresolved ?? new Set(),
81
+ freeBuckets: [],
67
82
  };
83
+ index.locations.clear();
84
+ index.order.clear();
85
+ index.unresolved.clear();
68
86
  for (const instanceId of candidates()) {
87
+ if (index.order.has(instanceId))
88
+ continue;
89
+ index.order.set(instanceId, index.nextOrder++);
69
90
  const position = resolvePosition(instanceId);
70
- if (position === undefined)
91
+ if (position === undefined) {
92
+ index.unresolved.add(instanceId);
71
93
  continue;
94
+ }
72
95
  indexed.add(instanceId);
73
96
  const key = packCell(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
97
+ index.locations.set(instanceId, key);
74
98
  let bucket = cells.get(key);
75
99
  if (bucket === undefined) {
76
100
  bucket = takeBucket(index);
@@ -80,8 +104,70 @@ export function createSpatialApi(options) {
80
104
  }
81
105
  gridIndex = index;
82
106
  gridDirty = false;
107
+ membershipChanges = 0;
83
108
  return index;
84
109
  }
110
+ function updateEntity(instanceId, candidatePresent) {
111
+ const index = gridIndex;
112
+ if (index === null || gridDirty || cellSize === undefined)
113
+ return;
114
+ const position = candidatePresent === false ? undefined : resolvePosition(instanceId);
115
+ const present = candidatePresent ?? (position !== undefined);
116
+ const oldKey = index.locations.get(instanceId);
117
+ const newKey = position === undefined ? undefined
118
+ : packCell(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
119
+ const had = index.order.has(instanceId);
120
+ if (had !== present) {
121
+ membershipChanges += 1;
122
+ if (membershipChanges > MEMBERSHIP_REBUILD_THRESHOLD) {
123
+ gridDirty = true;
124
+ return;
125
+ }
126
+ }
127
+ if (oldKey !== undefined && oldKey !== newKey) {
128
+ const bucket = index.cells.get(oldKey);
129
+ bucket.splice(bucket.indexOf(instanceId), 1);
130
+ if (bucket.length === 0) {
131
+ index.cells.delete(oldKey);
132
+ index.freeBuckets.push(bucket);
133
+ }
134
+ }
135
+ index.unresolved.delete(instanceId);
136
+ if (newKey === undefined) {
137
+ index.indexed.delete(instanceId);
138
+ index.locations.delete(instanceId);
139
+ if (present) {
140
+ if (!had)
141
+ index.order.set(instanceId, index.nextOrder++);
142
+ index.unresolved.add(instanceId);
143
+ }
144
+ else
145
+ index.order.delete(instanceId);
146
+ return;
147
+ }
148
+ if (!had)
149
+ index.order.set(instanceId, index.nextOrder++);
150
+ index.indexed.add(instanceId);
151
+ index.locations.set(instanceId, newKey);
152
+ if (oldKey === newKey)
153
+ return;
154
+ let bucket = index.cells.get(newKey);
155
+ if (bucket === undefined) {
156
+ bucket = takeBucket(index);
157
+ index.cells.set(newKey, bucket);
158
+ }
159
+ const ordinal = index.order.get(instanceId);
160
+ let low = 0;
161
+ let high = bucket.length;
162
+ while (low < high) {
163
+ const mid = (low + high) >>> 1;
164
+ if (index.order.get(bucket[mid]) < ordinal)
165
+ low = mid + 1;
166
+ else
167
+ high = mid;
168
+ }
169
+ bucket.splice(low, 0, instanceId);
170
+ }
85
171
  function collectNear(index, centerX, centerZ, radius, out) {
86
172
  const size = cellSize;
87
173
  const cxMin = cellCoord(centerX - radius, size);
@@ -109,6 +195,11 @@ export function createSpatialApi(options) {
109
195
  const nearScratch = [];
110
196
  const resultScratch = [];
111
197
  function appendUnindexed(index, out) {
198
+ if (options.incremental === true) {
199
+ for (const instanceId of index.unresolved)
200
+ out.push(instanceId);
201
+ return;
202
+ }
112
203
  for (const instanceId of candidates()) {
113
204
  if (index.indexed.has(instanceId))
114
205
  continue;
@@ -130,6 +221,7 @@ export function createSpatialApi(options) {
130
221
  return resultScratch.slice();
131
222
  }
132
223
  return {
224
+ updateEntity,
133
225
  distance(aInstanceId, bInstanceId) {
134
226
  const a = resolvePosition(aInstanceId);
135
227
  const b = resolvePosition(bInstanceId);
@@ -1,4 +1,7 @@
1
- import { MountController, type MountSeat, type RideableConfig } from "./mount.js";
1
+ import { MountController, type MountedRider, type MountSeat, type RideableConfig } from "./mount.js";
2
+ import type { GameContext } from "../runtime/gameContext.js";
3
+ import type { CameraRigKind } from "../game/cameraConfig.js";
4
+ import { type CameraRigConfig, type ChaseCameraTuning } from "../runtime/cameraDirector.js";
2
5
  /** World-space `[x, y, z]` for a vehicle's current position. */
3
6
  export type VehiclePosition = readonly [number, number, number];
4
7
  /** A vehicle's world-space position and heading, used for {@link VehicleSeats.exit}'s dismount placement math. */
@@ -38,7 +41,7 @@ export type ExitVehicleResult = {
38
41
  riderMovementPatch: RiderMovementPatch;
39
42
  } | {
40
43
  ok: false;
41
- reason: "not_seated";
44
+ reason: "not_seated" | "invalid_pose";
42
45
  };
43
46
  /** Where {@link VehicleSeats.exit} steps the rider out, relative to the vehicle's heading. */
44
47
  export interface DismountOffset {
@@ -62,6 +65,11 @@ export declare class VehicleSeats {
62
65
  exit(riderId: string, vehiclePose: VehiclePose, offset?: DismountOffset): ExitVehicleResult;
63
66
  isSeated(riderId: string): boolean;
64
67
  driverOf(vehicleId: string): string | null;
68
+ /** The seated rider's controlled mount; `null` for passengers and unseated riders. */
69
+ drivenBy(riderId: string): string | null;
70
+ snapshot(): MountedRider[];
71
+ restore(next: readonly MountedRider[]): void;
72
+ reset(): void;
65
73
  get mounts(): MountController;
66
74
  }
67
75
  /**
@@ -70,3 +78,29 @@ export declare class VehicleSeats {
70
78
  * @capability vehicle-seats enter, exit and swap seats in a car or mount (GTA-style boarding) with driver and passenger slots
71
79
  */
72
80
  export declare function createVehicleSeats(controller?: MountController): VehicleSeats;
81
+ /** Seat selection and optional local camera policy for an explicit rider transition. */
82
+ export interface BoardVehicleOptions extends EnterVehicleOptions {
83
+ /** Presentation policy; `false` applies only rider/occupancy patches for authoritative remote riders. */
84
+ camera?: boolean;
85
+ /** Omit to keep the current rig. `null` restores the configured rig. */
86
+ rig?: CameraRigKind | null;
87
+ config?: CameraRigConfig;
88
+ chase?: ChaseCameraTuning | null;
89
+ }
90
+ /** Exit placement and optional local camera restoration for an explicit rider. */
91
+ export interface LeaveVehicleOptions extends DismountOffset {
92
+ camera?: boolean;
93
+ /** Supply a last known vehicle pose when its entity has already been removed. */
94
+ pose?: VehiclePose;
95
+ }
96
+ /** Apply an existing seat transition to the explicitly named rider. Motor/input policy stays caller-owned.
97
+ * @capability board-vehicle board a rideable, freeze/hide its rider and apply caller camera policy
98
+ */
99
+ export declare function boardVehicle(ctx: GameContext, seats: VehicleSeats, riderId: string, vehicleId: string, options?: BoardVehicleOptions): EnterVehicleResult;
100
+ /** Apply dismount placement, restore rider movement/visibility and the configured camera rig.
101
+ * @capability leave-vehicle leave a rideable beside its current pose and restore on-foot camera/movement
102
+ */
103
+ export declare function leaveVehicle(ctx: GameContext, seats: VehicleSeats, riderId: string, options?: LeaveVehicleOptions): ExitVehicleResult | {
104
+ ok: false;
105
+ reason: "unknown_rider" | "unknown_vehicle";
106
+ };