@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,3 +1,4 @@
1
+ import { createAuthoredSimulation } from "../world/authoredSimulation.js";
1
2
  import { createCommandRegistry } from "../commands/commandRegistry.js";
2
3
  import {} from "../game/cosmetics.js";
3
4
  import { groundFieldFor } from "../world/terrain.js";
@@ -12,6 +13,7 @@ import { createItemUse } from "../item/use.js";
12
13
  import { createWeaponStats } from "../item/weapon.js";
13
14
  import {} from "../scene/entityStats.js";
14
15
  import {} from "../scene/entityStore.js";
16
+ import { installPursuitPersistence } from "../scene/behaviorRuntime.js";
15
17
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
16
18
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
17
19
  import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
@@ -22,7 +24,7 @@ import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
22
24
  import { createSimClock } from "../time/simClock.js";
23
25
  import { createSimContext } from "./simContext.js";
24
26
  import { seededRng } from "../random/rng.js";
25
- import { createCameraDirector } from "./cameraDirector.js";
27
+ import { createCameraDirector, installCameraPersistence } from "./cameraDirector.js";
26
28
  import { createParticleDirector } from "../vfx/particleDirector.js";
27
29
  import { createInputSnapshot } from "./inputSnapshot.js";
28
30
  import { baselineDescriptors } from "./descriptors/baseline.js";
@@ -62,6 +64,7 @@ export function createGameContext(options) {
62
64
  content,
63
65
  signalNotify: signal.notify,
64
66
  ground,
67
+ solids,
65
68
  events,
66
69
  time,
67
70
  ...(occluder !== undefined ? { occluder } : {}),
@@ -195,12 +198,26 @@ export function createGameContext(options) {
195
198
  // --- Combat installer (effects, projectiles, death, combat FX) ---
196
199
  // Command runner closes over `ctx` once assembled (same late-bind pattern as before).
197
200
  let ctxRef = null;
201
+ const { sweep, acceleration, targets, ...projectileBudgets } = definition.projectileTravel ?? {};
202
+ function projectileContext() {
203
+ if (ctxRef === null)
204
+ throw new Error("Projectile providers require an assembled GameContext");
205
+ return ctxRef;
206
+ }
198
207
  const combat = createCombatSubsystem({
199
208
  content,
200
209
  signalNotify: signal.notify,
201
210
  now,
202
211
  events,
203
212
  time,
213
+ projectileTravel: {
214
+ ...projectileBudgets,
215
+ ...(sweep === undefined ? {} : { sweep: (from, to, step) => sweep(projectileContext(), from, to, step) }),
216
+ ...(targets === undefined ? {} : { targets: () => targets(projectileContext()) }),
217
+ acceleration: acceleration === undefined
218
+ ? (position, _velocity, at, input) => authoredEnvironment.accelerationAt(position, at, input.travel?.windResponse ?? 0, input.travel?.maxAcceleration ?? 0, input.travel?.forceMask ?? 0)
219
+ : (position, velocity, at, input) => acceleration(projectileContext(), position, velocity, at, input),
220
+ },
204
221
  entities,
205
222
  objects,
206
223
  combatSpatial,
@@ -214,12 +231,16 @@ export function createGameContext(options) {
214
231
  lootRegistry,
215
232
  spawnWorldItem,
216
233
  despawnEntity,
217
- runCommand(name, args) {
234
+ runCommand(name, args, actorUserId) {
218
235
  if (ctxRef === null)
219
236
  return;
220
- commandRegistry.run(ctxRef, name, args);
237
+ if (actorUserId === undefined)
238
+ ctxRef.game.commands.run(name, args);
239
+ else
240
+ ctxRef.game.commands.runAs(actorUserId, name, args);
221
241
  },
222
- localUserId: player.userId,
242
+ userIdOf: (instanceId) => instanceId === player.userId || featureValue("players")?.has(instanceId)
243
+ ? instanceId : possession.ownerOf(instanceId),
223
244
  rng,
224
245
  ...(definition.physics !== undefined ? { physics: definition.physics } : {}),
225
246
  });
@@ -227,8 +248,9 @@ export function createGameContext(options) {
227
248
  const { projectiles, combatFx, vfxInstances, effects } = combat;
228
249
  const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
229
250
  const { pile, loop, raceState, cardPiles, turnLoops } = createContextRegistries(signal.notify);
230
- const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
251
+ const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning", "setRig", "restore", "reset"], signal.notify);
231
252
  const particles = createParticleDirector();
253
+ const authoredEnvironment = createAuthoredSimulation({ document: definition.authoredDocument, timeSeconds: time.now, particles });
232
254
  const input = createInputSnapshot();
233
255
  // --- Descriptor install (single feature enable path) ---
234
256
  const featureDeps = {
@@ -246,6 +268,7 @@ export function createGameContext(options) {
246
268
  walletOf,
247
269
  setWallet: (userId, state) => wallets.set(userId, state),
248
270
  layouts,
271
+ itemTraits: playerSys.itemTraits,
249
272
  inventoryFor,
250
273
  ensureInstanceStats,
251
274
  seedUserPool,
@@ -342,6 +365,20 @@ export function createGameContext(options) {
342
365
  });
343
366
  saveModules.push(module);
344
367
  }
368
+ function reviveRestoredEntities(snapshot) {
369
+ if (!Object.hasOwn(snapshot, "entities") || !Array.isArray(snapshot["entities"]))
370
+ return;
371
+ for (const entity of entities.list()) {
372
+ const receive = content.entityById?.(entity.name)?.receive;
373
+ const alive = Object.values(receive ?? {}).every((rule) => {
374
+ const terminal = rule.order[rule.order.length - 1];
375
+ const pool = terminal === undefined ? undefined : statsByInstance.get(entity.id)?.[terminal];
376
+ return pool === undefined || pool.current > pool.min;
377
+ });
378
+ if (alive)
379
+ combat.death.revive(entity.id);
380
+ }
381
+ }
345
382
  // --- Assemble public GameContext ---
346
383
  const ctx = {
347
384
  rng,
@@ -352,11 +389,13 @@ export function createGameContext(options) {
352
389
  despawn: despawnEntity,
353
390
  update: entities.update,
354
391
  setPose: entities.setPose,
392
+ setVelocity: entities.setVelocity,
355
393
  setPoseConstraint: entities.setPoseConstraint,
356
394
  get: entities.get,
357
395
  list: entities.list,
358
396
  ids: entities.ids,
359
397
  subscribeMembership: entities.subscribeMembership,
398
+ subscribeBehaviors: entities.subscribeBehaviors,
360
399
  spawnPoseOf: entities.spawnPoseOf,
361
400
  resetToSpawn: entities.resetToSpawn,
362
401
  resetAllToSpawn,
@@ -376,6 +415,9 @@ export function createGameContext(options) {
376
415
  willHitProjectile: projectiles.willHitProjectile,
377
416
  fireProjectile: projectiles.fireProjectile,
378
417
  settleProjectile: projectiles.settleProjectile,
418
+ activeProjectiles: projectiles.activeProjectiles,
419
+ projectileState: projectiles.snapshot,
420
+ restoreProjectiles: projectiles.restore,
379
421
  distance: spatial.distance,
380
422
  inRadius: spatial.inRadius,
381
423
  hasLineOfSight: spatial.hasLineOfSight,
@@ -409,6 +451,7 @@ export function createGameContext(options) {
409
451
  raycast: (input) => sceneRaycast.raycast(input),
410
452
  raycastAll: (input) => sceneRaycast.raycastAll(input),
411
453
  },
454
+ environment: authoredEnvironment,
412
455
  world: {
413
456
  ground,
414
457
  groundHeightAt: ground.sampleHeight,
@@ -507,7 +550,7 @@ export function createGameContext(options) {
507
550
  weapon,
508
551
  },
509
552
  time,
510
- sim: createSimContext({ config: definition.simulation, entities }),
553
+ sim: createSimContext({ config: definition.simulation, entities, time }),
511
554
  camera,
512
555
  particles,
513
556
  input,
@@ -517,24 +560,46 @@ export function createGameContext(options) {
517
560
  snapshot: (viewer) => composeWorldSnapshot(replicationModules, viewer),
518
561
  hydrate(snapshot) {
519
562
  applyWorldSnapshot(replicationModules, snapshot);
563
+ reviveRestoredEntities(snapshot);
564
+ signal.notify();
565
+ },
566
+ state: () => structuredClone(composeWorldSnapshot(saveModules)),
567
+ restore(state) {
568
+ applyWorldSnapshot(saveModules, structuredClone(state));
569
+ reviveRestoredEntities(state);
520
570
  signal.notify();
521
571
  },
522
572
  replicationVersion,
523
573
  replicatesPerViewer: () => projectsViewers,
524
574
  };
525
575
  ctxRef = ctx;
576
+ ctx.sim.addStage({ id: "authored-environment", phase: "afterTick", run: (_dt, _tick, gameDt) => authoredEnvironment.step(gameDt) });
577
+ ctx.sim.addStage({ id: "projectile-targets", phase: "beforeMovement", run: (_dt, _tick, gameDt) => combat.captureProjectileTargets(gameDt) });
578
+ ctx.sim.addStage({ id: "projectile-travel", phase: "afterTick", run: (_dt, _tick, gameDt) => projectiles.advanceProjectiles(gameDt, time.now()) });
579
+ registerSave({ key: "environment", snapshot: authoredEnvironment.snapshot, hydrate: (state) => authoredEnvironment.restore(state) });
580
+ registerSave({ key: "projectiles", snapshot: projectiles.snapshot, hydrate: (state) => projectiles.restore(state) });
581
+ registerSave({
582
+ key: "sim",
583
+ snapshot: () => ctx.sim.snapshot(),
584
+ hydrate: (state) => ctx.sim.restore(state),
585
+ decode: (raw) => {
586
+ if (raw === null || typeof raw !== "object" || !("loop" in raw) || !("poses" in raw))
587
+ return null;
588
+ return raw;
589
+ },
590
+ });
526
591
  const physicsBackend = definition.physics?.backend;
527
592
  if (physicsBackend !== undefined) {
528
- ctx.sim.addStage({ id: "physics", phase: "afterMovement", run: (dt) => physicsBackend.step(dt) });
593
+ ctx.sim.addStage({ id: "physics", phase: "afterMovement", run: (_dt, _tick, gameDt) => { if (gameDt > 0)
594
+ physicsBackend.step(gameDt); } });
529
595
  }
596
+ installPursuitPersistence(ctx, aoiRadius, definition.pursuit?.eligible);
597
+ installCameraPersistence(ctx);
530
598
  const saveOptions = resolveSaveOptions(definition, options);
531
599
  if (saveOptions !== undefined) {
532
600
  const saveTarget = {
533
- snapshot: () => composeWorldSnapshot(saveModules),
534
- hydrate: (snapshot) => {
535
- applyWorldSnapshot(saveModules, snapshot);
536
- signal.notify();
537
- },
601
+ snapshot: ctx.state,
602
+ hydrate: ctx.restore,
538
603
  subscribe: signal.subscribe,
539
604
  };
540
605
  ctx.game.save = createRuntimeSave({ target: saveTarget, ...saveOptions });
@@ -561,10 +626,11 @@ function resolveSaveOptions(definition, options) {
561
626
  return undefined;
562
627
  const config = persist === undefined || persist === true ? {} : persist;
563
628
  return {
564
- backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
629
+ backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(undefined, { errorMode: "throw" }),
565
630
  mode: config.mode,
566
631
  key: saveKey(definition.name),
567
632
  version: config.version,
633
+ migrate: config.migrate,
568
634
  autosaveMs: config.autosaveMs,
569
635
  now: options.now,
570
636
  };
@@ -1,3 +1,4 @@
1
+ import type { AuthoredSimulationRuntime } from "../world/authoredSimulation.js";
1
2
  import type { Territory } from "../world/territory.js";
2
3
  import type { CurrencyDefinition } from "../economy/currency.js";
3
4
  import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
@@ -257,11 +258,14 @@ export interface SceneEntityContext {
257
258
  despawn(instanceId: string): boolean;
258
259
  update: EntityStore["update"];
259
260
  setPose(instanceId: string, pose: EntityPose): boolean;
261
+ /** Set finite world velocity without changing pose; invalid tuples and missing entities return false unchanged. */
262
+ setVelocity: EntityStore["setVelocity"];
260
263
  setPoseConstraint: EntityStore["setPoseConstraint"];
261
264
  get(instanceId: string): SceneEntity | null;
262
265
  list(): readonly SceneEntity[];
263
266
  ids: EntityStore["ids"];
264
267
  subscribeMembership: EntityStore["subscribeMembership"];
268
+ subscribeBehaviors: EntityStore["subscribeBehaviors"];
265
269
  spawnPoseOf(instanceId: string): SpawnPose | null;
266
270
  resetToSpawn(instanceId: string): boolean;
267
271
  resetAllToSpawn(filter?: (entity: SceneEntity) => boolean): number;
@@ -287,6 +291,10 @@ export interface SceneEntityContext {
287
291
  willHitProjectile: ProjectileSystem["willHitProjectile"];
288
292
  fireProjectile: ProjectileSystem["fireProjectile"];
289
293
  settleProjectile: ProjectileSystem["settleProjectile"];
294
+ /** Live travel poses for game-owned arrow/projectile appearance. */
295
+ activeProjectiles: ProjectileSystem["activeProjectiles"];
296
+ projectileState: ProjectileSystem["snapshot"];
297
+ restoreProjectiles: ProjectileSystem["restore"];
290
298
  distance: SpatialApi["distance"];
291
299
  inRadius: SpatialApi["inRadius"];
292
300
  hasLineOfSight: SpatialApi["hasLineOfSight"];
@@ -458,6 +466,8 @@ export interface GameContext {
458
466
  raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
459
467
  };
460
468
  world: GameContextWorld;
469
+ /** Authored weather, shelter, accumulation and fire sampled from ctx.time. */
470
+ environment: AuthoredSimulationRuntime;
461
471
  /**
462
472
  * Deterministic `[0,1)` stream for this world — loot, AI, combat rolls, and any sim path that
463
473
  * needs randomness. Same seed → same sequence. Never fall back to `Math.random` in systems;
@@ -571,6 +581,10 @@ export interface GameContext {
571
581
  snapshot(viewer?: SnapshotViewer): WorldSnapshot;
572
582
  /** Apply a {@link WorldSnapshot} from an authoritative host, hydrating each subsystem key present in it. */
573
583
  hydrate(snapshot: WorldSnapshot): void;
584
+ /** Detached authoritative state, including save-only modules. Use for persistence, never client replication; available without `persist` or a save backend. */
585
+ state(): WorldSnapshot;
586
+ /** Restore authoritative state after initialization. Missing keys retain their initialized values, including in legacy replication-only saves. */
587
+ restore(state: WorldSnapshot): void;
574
588
  /** Aggregate world-dirty version summed across replicated modules; the host replicator skips an unchanged commit. @internal */
575
589
  replicationVersion(): number;
576
590
  /** True when a {@link GameContextOptions.replication} policy makes {@link snapshot} viewer-dependent (private/AOI projection is active). @internal */
@@ -2,10 +2,12 @@ import type { GameDefinition, GameLoop } from "../game/defineGame.js";
2
2
  import type { PointerAxisState } from "../input/pointerAxis.js";
3
3
  import type { ModelAssetRef } from "../scene/assetCatalog.js";
4
4
  import { type GameContext, type GameContextContent, type GameContextModels } from "./gameContext.js";
5
- import type { InputSnapshot } from "./inputSnapshot.js";
5
+ import type { InputFrame, InputSnapshot } from "./inputSnapshot.js";
6
6
  import { type SimSnapshot } from "./simSnapshot.js";
7
7
  /** One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame. */
8
8
  export interface HeadlessInput {
9
+ /** Discrete presses on this recorded step, including taps released before its held-state sample. */
10
+ presses?: InputFrame["presses"];
9
11
  /** Replaces the held-action set for this step; edge detection (`justPressed`/`justReleased`) rolls the previous set forward. Omit to keep the last published set. */
10
12
  held?: readonly string[];
11
13
  /** Replaces the normalized pointer state; omit to leave the last pointer unchanged (pass `null` to clear it). */
@@ -18,15 +18,14 @@ export function createHeadlessRunner(options) {
18
18
  loop?.onNewPlayer?.(ctx);
19
19
  const simSnapshot = createContextSimSnapshot(ctx);
20
20
  const tuning = options.playerMovement === true
21
- ? resolvePlayerMovementTuning({
22
- physics: definition.physics,
23
- world: definition.world,
24
- movement: options.movement,
25
- })
21
+ ? { ...resolvePlayerMovementTuning({
22
+ physics: definition.physics,
23
+ world: definition.world,
24
+ movement: options.movement,
25
+ }), authoritativeStep: true }
26
26
  : null;
27
27
  function publishInput(input) {
28
- if (input.held !== undefined)
29
- ctx.input.publish(input.held);
28
+ ctx.input.publish(input.held ?? ctx.input.held(), { pressed: input.presses?.map(press => press.action) ?? [] });
30
29
  if (input.pointer !== undefined)
31
30
  ctx.input.publishPointer(input.pointer);
32
31
  }
@@ -54,12 +53,12 @@ export function createHeadlessRunner(options) {
54
53
  publishInput(input);
55
54
  const dt = Math.min(dtSeconds, maxStep);
56
55
  let total = 0;
57
- ctx.sim.advance(dt, (stepDt) => {
58
- const gameDt = ctx.time.advance(stepDt);
56
+ ctx.sim.advance(dt, (stepDt, _tick, gameDt) => {
57
+ ctx.input.beginStep();
59
58
  total += gameDt;
60
59
  ctx.sim.runStages("beforeMovement", stepDt);
61
- if (tuning !== null) {
62
- stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, stepDt, tuning, options.heading, options.pitch);
60
+ if (tuning !== null && gameDt > 0) {
61
+ stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, options.heading, options.pitch);
63
62
  }
64
63
  ctx.sim.runStages("afterMovement", stepDt);
65
64
  loop?.onTick?.(ctx, gameDt);
@@ -71,6 +70,7 @@ export function createHeadlessRunner(options) {
71
70
  snapshot: () => simSnapshot.capture(),
72
71
  restore(snapshot) {
73
72
  simSnapshot.restore(snapshot);
73
+ ctx.input.publish([], { reset: true });
74
74
  },
75
75
  tick: () => ctx.sim.tick(),
76
76
  };
@@ -21,8 +21,17 @@ export interface HostedGameRunnerOptions<TAssetRef extends ModelAssetRef, TMulti
21
21
  * snapshot overlays the world state it seeded. Omit for a long-lived stateful host (ws) that keeps one runner.
22
22
  */
23
23
  restore?: WorldSnapshot;
24
+ /** Persisted replication cursor; restored hosts continue above it. */
25
+ revision?: number;
24
26
  /** Render-model lookup for collider auto-fit — pass the same lookup the shell derives from `entityModels`/`objectModels` so host and clients resolve identical hitboxes. */
25
27
  models?: GameContextModels;
28
+ /** Process-local reconnect protection. Departed admission marks expire after 5 minutes or the oldest is evicted above 256 users; zero disables retention. Live members and staged replay ticks are never evicted. */
29
+ inputRetention?: {
30
+ maxDepartedUsers?: number;
31
+ departedTtlMs?: number;
32
+ /** Wall-clock milliseconds; injectable for deterministic hosts and tests. */
33
+ nowMs?: () => number;
34
+ };
26
35
  }
27
36
  /**
28
37
  * The GameContext-loop equivalent of the pure-reducer `createGameHost`: one authoritative `createGameContext`
@@ -36,8 +45,18 @@ export interface HostedGameRunner {
36
45
  /** Re-attach an already-joined member on a reconstructed runner — restores membership and `ctx.game.players` without re-firing `onNewPlayer`. Stateless hosts call it once per persisted member after `restore`. */
37
46
  resume(userId: string): void;
38
47
  leave(userId: string): void;
39
- /** Record a client's latest input frame — stashed for {@link heldInput} and mirrored onto `ctx.game.players` so the movement seam reads each player's intent in `onTick`. */
48
+ /** Record a joined/resumed client's latest input frame; nonmember input is ignored. Stashed for {@link heldInput} and sampled onto `ctx.game.players` in `onTick`. */
40
49
  input(userId: string, frame: InputFrame): void;
50
+ /** Counts actual retained input storage for host health checks; elapsed recorder history is excluded and staged future replay ticks remain. */
51
+ inputStats(): {
52
+ members: number;
53
+ recorders: number;
54
+ recordedFrames: number;
55
+ latestInputs: number;
56
+ wireSequences: number;
57
+ pressSequences: number;
58
+ departedUsers: number;
59
+ };
41
60
  heldInput(userId: string): InputFrame | null;
42
61
  command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
43
62
  tick(dt: number): number;
@@ -45,6 +64,10 @@ export interface HostedGameRunner {
45
64
  revision(): number;
46
65
  /** The full world baseline — projected to only what `viewer` may see when the context carries a replication policy, else the whole world. */
47
66
  snapshot(viewer?: SnapshotViewer): WorldSnapshot;
67
+ /** Complete detached authoritative state for persistence, including save-only modules. */
68
+ state(): WorldSnapshot;
69
+ /** Commit replication without advancing simulation or running game ticks. */
70
+ commit(): number;
48
71
  /** True when {@link snapshot} is viewer-dependent (a replication policy projects private/AOI state); a host must then serve each viewer its own frame. */
49
72
  projectsViewers(): boolean;
50
73
  members(): readonly string[];
@@ -24,29 +24,142 @@ export function createHostedGameRunner(options) {
24
24
  const loop = definition.loop ?? {};
25
25
  const replicator = createWorldReplicator(() => ctx.snapshot(), {
26
26
  worldVersion: () => ctx.replicationVersion(),
27
+ initialRevision: options.revision ?? 0,
27
28
  });
28
29
  const members = new Map();
29
30
  const inputs = new Map();
30
31
  const latestInputs = new Map();
31
- const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics });
32
+ const movementTuning = { ...resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics }), authoritativeStep: true };
32
33
  const inputSeq = new Map();
34
+ const inputPressSeq = new Map();
35
+ const departed = new Map();
36
+ const maxDepartedUsers = options.inputRetention?.maxDepartedUsers ?? 256;
37
+ const departedTtlMs = options.inputRetention?.departedTtlMs ?? 300_000;
38
+ const inputNowMs = options.inputRetention?.nowMs ?? Date.now;
39
+ if (!Number.isInteger(maxDepartedUsers) || maxDepartedUsers < 0 || !Number.isFinite(departedTtlMs) || departedTtlMs < 0) {
40
+ throw new RangeError("inputRetention requires a nonnegative integer user limit and finite nonnegative TTL");
41
+ }
42
+ let retentionTime = -Infinity;
43
+ const retentionNow = () => {
44
+ const time = inputNowMs();
45
+ if (!Number.isFinite(time))
46
+ throw new RangeError("inputRetention.nowMs must return finite milliseconds");
47
+ return retentionTime = Math.max(retentionTime, time);
48
+ };
49
+ const expireDeparted = () => {
50
+ const time = retentionNow();
51
+ // Monotone leave times keep insertion order equal to expiry order, even if
52
+ // the wall clock reverses. Live ingress inspects only the first tombstone.
53
+ while (departed.size > 0) {
54
+ const [userId, expiresAt] = departed.entries().next().value;
55
+ if (expiresAt > time && departed.size <= maxDepartedUsers)
56
+ break;
57
+ departed.delete(userId);
58
+ inputSeq.delete(userId);
59
+ inputPressSeq.delete(userId);
60
+ }
61
+ };
62
+ const compactRecorder = (recorder, tick) => {
63
+ const continuous = recorder.frameAt(tick);
64
+ const frames = recorder.frames().filter(entry => entry.tick > tick);
65
+ if (continuous !== null) {
66
+ const { presses: _consumed, ...held } = continuous;
67
+ frames.unshift({ tick, frame: held });
68
+ }
69
+ recorder.restore({ frames });
70
+ };
33
71
  let hostTick = 0;
72
+ const inputModule = {
73
+ key: "hostInput",
74
+ snapshot() {
75
+ const tick = ctx.sim.tick();
76
+ const saved = [];
77
+ for (const userId of members.keys()) {
78
+ const recorder = inputs.get(userId);
79
+ const latest = latestInputs.get(userId);
80
+ if (recorder === undefined || latest === undefined)
81
+ continue;
82
+ const continuous = recorder.frameAt(tick);
83
+ // Keep continuous intent and unconsumed future ticks, never elapsed history.
84
+ // Live input coalesces onto the next tick; explicit replay may stage future ticks.
85
+ const frames = recorder.frames().filter(entry => entry.tick > tick);
86
+ if (continuous !== null) {
87
+ const { presses: _consumed, ...held } = continuous;
88
+ frames.unshift({ tick, frame: held });
89
+ }
90
+ const seq = inputSeq.get(userId);
91
+ const pressSeq = inputPressSeq.get(userId);
92
+ saved.push({ userId, latest, recorder: { frames }, ...(seq === undefined ? {} : { seq }), ...(pressSeq === undefined ? {} : { pressSeq }) });
93
+ }
94
+ return saved;
95
+ },
96
+ hydrate(saved) {
97
+ inputs.clear();
98
+ latestInputs.clear();
99
+ inputSeq.clear();
100
+ inputPressSeq.clear();
101
+ departed.clear();
102
+ for (const input of saved) {
103
+ const recorder = createInputRecorder();
104
+ recorder.restore(input.recorder);
105
+ inputs.set(input.userId, recorder);
106
+ latestInputs.set(input.userId, input.latest);
107
+ if (input.seq !== undefined)
108
+ inputSeq.set(input.userId, input.seq);
109
+ if (input.pressSeq !== undefined)
110
+ inputPressSeq.set(input.userId, input.pressSeq);
111
+ }
112
+ },
113
+ decode(raw) {
114
+ const frame = (value) => {
115
+ if (value === null || typeof value !== "object" || !("held" in value) || !Array.isArray(value.held) || !value.held.every(action => typeof action === "string"))
116
+ return false;
117
+ if (!("pointer" in value))
118
+ return false;
119
+ const pointer = value.pointer;
120
+ if (pointer !== null && (typeof pointer !== "object" || !("x" in pointer) || !("y" in pointer) || !("active" in pointer) || !Number.isFinite(pointer.x) || !Number.isFinite(pointer.y) || typeof pointer.active !== "boolean"))
121
+ return false;
122
+ if ("tick" in value && value.tick !== undefined && !Number.isFinite(value.tick))
123
+ return false;
124
+ if ("presses" in value && value.presses !== undefined && (!Array.isArray(value.presses) || !value.presses.every(press => press !== null && typeof press === "object" && typeof press.action === "string" && Number.isFinite(press.seq))))
125
+ return false;
126
+ if ("analog" in value && value.analog !== undefined && value.analog !== null && (typeof value.analog !== "object" || Array.isArray(value.analog) || !Object.values(value.analog).every(Number.isFinite)))
127
+ return false;
128
+ return true;
129
+ };
130
+ if (!Array.isArray(raw) || !raw.every(input => input !== null && typeof input === "object" && typeof input.userId === "string" &&
131
+ (input.seq === undefined || Number.isFinite(input.seq)) && (input.pressSeq === undefined || Number.isFinite(input.pressSeq)) && frame(input.latest) &&
132
+ input.recorder !== null && typeof input.recorder === "object" && Array.isArray(input.recorder.frames) &&
133
+ input.recorder.frames.every((entry) => entry !== null && typeof entry === "object" && Number.isFinite(entry.tick) && frame(entry.frame))))
134
+ return null;
135
+ if (new Set(raw.map(input => input.userId)).size !== raw.length)
136
+ return null;
137
+ return raw;
138
+ },
139
+ };
140
+ ctx.game.registerSave?.(inputModule);
34
141
  loop.onInit?.(ctx);
35
142
  syncLifecyclePhase(ctx, definition.lifecycle);
36
143
  if (restore !== undefined)
37
- ctx.hydrate(restore);
144
+ ctx.restore(restore);
38
145
  return {
39
146
  join(userId, isNew) {
147
+ expireDeparted();
40
148
  if (members.has(userId))
41
149
  return;
150
+ departed.delete(userId);
42
151
  const player = { userId, isNew };
43
152
  members.set(userId, player);
153
+ ctx.player.possession.own(userId, userId);
44
154
  ctx.game.players?.join(userId, isNew);
45
155
  loop.onNewPlayer?.(ctx, player);
46
156
  syncLifecyclePhase(ctx, definition.lifecycle);
47
157
  },
48
158
  resume(userId) {
159
+ expireDeparted();
160
+ departed.delete(userId);
49
161
  members.set(userId, { userId, isNew: false });
162
+ ctx.player.possession.own(userId, userId);
50
163
  ctx.game.players?.join(userId, false);
51
164
  },
52
165
  leave(userId) {
@@ -56,12 +169,22 @@ export function createHostedGameRunner(options) {
56
169
  members.delete(userId);
57
170
  inputs.delete(userId);
58
171
  latestInputs.delete(userId);
59
- inputSeq.delete(userId);
172
+ // Retain only bounded, recent admission marks; departed ingress is ignored.
173
+ if (inputSeq.has(userId) || inputPressSeq.has(userId)) {
174
+ departed.delete(userId);
175
+ departed.set(userId, retentionNow() + departedTtlMs);
176
+ }
177
+ expireDeparted();
60
178
  loop.onPlayerLeave?.(ctx, player);
61
179
  ctx.game.players?.leave(userId);
62
180
  },
63
181
  input(userId, frame) {
182
+ expireDeparted();
183
+ if (!members.has(userId))
184
+ return;
64
185
  const seq = frame.seq;
186
+ if ((seq !== undefined && !Number.isFinite(seq)) || (frame.tick !== undefined && !Number.isFinite(frame.tick)))
187
+ return;
65
188
  if (seq !== undefined) {
66
189
  const lastSeq = inputSeq.get(userId);
67
190
  if (lastSeq !== undefined && seq <= lastSeq)
@@ -73,8 +196,26 @@ export function createHostedGameRunner(options) {
73
196
  recorder = createInputRecorder();
74
197
  inputs.set(userId, recorder);
75
198
  }
76
- recorder.record(frame.tick ?? ctx.sim.tick() + 1, frame);
77
- latestInputs.set(userId, frame);
199
+ let admitted = frame;
200
+ if (frame.presses !== undefined) {
201
+ let lastPress = inputPressSeq.get(userId) ?? -Infinity;
202
+ const presses = [...frame.presses].sort((a, b) => a.seq - b.seq).filter(press => {
203
+ if (!Number.isFinite(press.seq) || press.seq <= lastPress)
204
+ return false;
205
+ lastPress = press.seq;
206
+ return true;
207
+ });
208
+ if (presses.length > 0)
209
+ inputPressSeq.set(userId, lastPress);
210
+ admitted = { ...frame, presses };
211
+ }
212
+ recorder.record(frame.tick ?? ctx.sim.tick() + 1, admitted);
213
+ compactRecorder(recorder, ctx.sim.tick());
214
+ latestInputs.set(userId, admitted);
215
+ },
216
+ inputStats() {
217
+ expireDeparted();
218
+ return { members: members.size, recorders: inputs.size, recordedFrames: [...inputs.values()].reduce((count, recorder) => count + recorder.frames().length, 0), latestInputs: latestInputs.size, wireSequences: inputSeq.size, pressSequences: inputPressSeq.size, departedUsers: departed.size };
78
219
  },
79
220
  heldInput(userId) {
80
221
  return latestInputs.get(userId) ?? null;
@@ -83,8 +224,9 @@ export function createHostedGameRunner(options) {
83
224
  return ctx.game.commands.runAs(userId, name, input);
84
225
  },
85
226
  tick(dt) {
227
+ expireDeparted();
86
228
  hostTick += 1;
87
- ctx.sim.advance(dt, (stepDt, tick) => {
229
+ ctx.sim.advance(dt, (stepDt, tick, gameDt) => {
88
230
  for (const [userId, recorder] of inputs) {
89
231
  const frame = recorder.frameAt(tick);
90
232
  if (frame !== null)
@@ -93,13 +235,15 @@ export function createHostedGameRunner(options) {
93
235
  ctx.sim.runStages("beforeMovement", stepDt);
94
236
  for (const userId of members.keys()) {
95
237
  const frame = inputs.get(userId)?.frameAt(tick) ?? null;
96
- if (frame !== null)
97
- serverStep(ctx, userId, frame, stepDt, movementTuning);
238
+ if (frame !== null && gameDt > 0)
239
+ serverStep(ctx, userId, frame, gameDt, movementTuning);
98
240
  }
99
241
  ctx.sim.runStages("afterMovement", stepDt);
100
- loop.onTick?.(ctx, stepDt);
101
- advanceBehaviors(ctx, stepDt);
242
+ loop.onTick?.(ctx, gameDt);
243
+ advanceBehaviors(ctx, gameDt);
102
244
  ctx.sim.runStages("afterTick", stepDt);
245
+ for (const recorder of inputs.values())
246
+ compactRecorder(recorder, tick);
103
247
  });
104
248
  syncLifecyclePhase(ctx, definition.lifecycle);
105
249
  return replicator.commit();
@@ -107,6 +251,8 @@ export function createHostedGameRunner(options) {
107
251
  diff: (sinceRevision) => replicator.diff(sinceRevision),
108
252
  revision: () => replicator.revision(),
109
253
  snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }),
254
+ state: ctx.state,
255
+ commit: () => replicator.commit(),
110
256
  projectsViewers: () => ctx.replicatesPerViewer(),
111
257
  members: () => Array.from(members.keys()),
112
258
  context: () => ctx,