@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
@@ -7,7 +7,12 @@ import type { WorldDiff } from "./worldReplication.js";
7
7
  import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
8
8
  import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
9
9
  export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
10
- /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host.
10
+ /** @internal Decode the retry envelope shared by authoritative hosts. */
11
+ export declare function unwrapOpEnvelope(input: unknown): {
12
+ opId: string;
13
+ input: unknown;
14
+ } | null;
15
+ /** Detached in-process {@link SyncHostedWorldStore} for tests, local play, and the browser-tab P2P host.
11
16
  * @internal
12
17
  */
13
18
  export declare function memoryWorldStore(seed?: HostedWorldRecord): SyncHostedWorldStore;
@@ -30,8 +35,10 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
30
35
  now?: () => number;
31
36
  /** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
32
37
  store?: SyncHostedWorldStore;
33
- /** Minimum elapsed ms between auto-saves on tick, measured against `now` (defaults to `Date.now` when omitted). Default `0` — persist on every revision-changing tick. */
38
+ /** Minimum elapsed ms between automatic saves, measured against `now` (default `Date.now`). Default `0`; private clock/simulation changes also count. A pending write defers automatic capture until a later tick. */
34
39
  saveIntervalMs?: number;
40
+ /** Retained retry receipts per player. Default `64`; retries after eviction are new operations. */
41
+ operationHistoryLimit?: number;
35
42
  /** Render-model lookup for collider auto-fit, forwarded to the runner — see {@link HostedGameRunnerOptions.models}. */
36
43
  models?: GameContextModels;
37
44
  }
@@ -43,11 +50,14 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
43
50
  */
44
51
  export interface HostedWorldSession {
45
52
  join(userId: string, isNew: boolean): void;
53
+ /** Whether this identity has joined before, including across leave/restart. */
54
+ hasPlayer(userId: string): boolean;
46
55
  leave(userId: string): void;
47
56
  input(userId: string, frame: InputFrame): void;
48
- command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
57
+ /** Optional stable operation id deduplicates retries across reconnect/restart; the receipt persists atomically with state. */
58
+ command(userId: string, name: string, input: unknown, operationId?: string): CommandResult<GameContext>;
49
59
  tick(dt: number): number;
50
- /** Replication pull: `null`/`0` cursor → full baseline; a prior revision → a diff since it. */
60
+ /** Replication pull: a fresh, pre-restart or future cursor receives a baseline; an in-session revision receives a diff. */
51
61
  sync(sinceRevision: number | null): HostedWorldSync;
52
62
  /** Alias used by host transports when pulling the next baseline or diff for a subscriber. */
53
63
  pull(sinceRevision: number | null): HostedWorldSync;
@@ -58,7 +68,9 @@ export interface HostedWorldSession {
58
68
  revision(): number;
59
69
  members(): readonly string[];
60
70
  /** Force-persist the current world to the store. */
61
- save(): void;
71
+ save(): void | Promise<void>;
72
+ /** Most recent automatic save failure; cleared by a successful write. Explicit `save()` also rejects. */
73
+ persistenceError(): unknown | null;
62
74
  runner(): HostedGameRunner;
63
75
  }
64
76
  /** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
@@ -1,22 +1,34 @@
1
1
  import { createHostedGameRunner } from "./hostedGameRunner.js";
2
- /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host.
2
+ import { applyWorldSnapshot } from "./worldSnapshot.js";
3
+ /** @internal Decode the retry envelope shared by authoritative hosts. */
4
+ export function unwrapOpEnvelope(input) {
5
+ if (typeof input !== "object" || input === null || Array.isArray(input))
6
+ return null;
7
+ const record = input;
8
+ const opId = record["__jgWsOpId"];
9
+ if (typeof opId !== "string")
10
+ return null;
11
+ const { __jgWsOpId: _opId, ...rest } = record;
12
+ return { opId, input: rest };
13
+ }
14
+ /** Detached in-process {@link SyncHostedWorldStore} for tests, local play, and the browser-tab P2P host.
3
15
  * @internal
4
16
  */
5
17
  export function memoryWorldStore(seed) {
6
- let record = seed ?? null;
18
+ let record = seed === undefined ? null : structuredClone(seed);
7
19
  return {
8
- load: () => record,
20
+ load: () => record === null ? null : structuredClone(record),
9
21
  save(next) {
10
- record = { snapshot: next.snapshot, revision: next.revision };
22
+ record = structuredClone(next);
11
23
  },
12
24
  };
13
25
  }
14
26
  /** Async in-process store for callers exercising the production persistence contract. */
15
27
  export function asyncMemoryWorldStore(seed) {
16
- let record = seed ?? null;
28
+ let record = seed === undefined ? null : structuredClone(seed);
17
29
  return {
18
- async load() { return record; },
19
- async save(next) { record = { snapshot: next.snapshot, revision: next.revision }; },
30
+ async load() { return record === null ? null : structuredClone(record); },
31
+ async save(next) { record = structuredClone(next); },
20
32
  };
21
33
  }
22
34
  /** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
@@ -33,32 +45,157 @@ export function createHostedWorldSession(options) {
33
45
  ...(host === undefined ? {} : { host }),
34
46
  ...(now === undefined ? {} : { now }),
35
47
  ...(loaded === null ? {} : { restore: loaded.snapshot }),
48
+ revision: loaded?.revision ?? 0,
36
49
  ...(options.models === undefined ? {} : { models: options.models }),
37
50
  });
51
+ const knownPlayers = new Set();
52
+ const operations = new Map();
53
+ const configuredHistory = options.operationHistoryLimit ?? 64;
54
+ const historyLimit = Number.isFinite(configuredHistory) ? Math.max(1, Math.floor(configuredHistory)) : 64;
55
+ const sessionModule = {
56
+ key: "hostSession",
57
+ snapshot: () => ({ players: [...knownPlayers], operations: [...operations] }),
58
+ hydrate(state) {
59
+ knownPlayers.clear();
60
+ for (const userId of state.players)
61
+ knownPlayers.add(userId);
62
+ operations.clear();
63
+ for (const [userId, receipts] of state.operations)
64
+ operations.set(userId, receipts.slice(-historyLimit));
65
+ },
66
+ decode(raw) {
67
+ if (raw === null || typeof raw !== "object" || !("players" in raw) || !("operations" in raw))
68
+ return null;
69
+ if (!Array.isArray(raw.players) || !raw.players.every((id) => typeof id === "string") || !Array.isArray(raw.operations))
70
+ return null;
71
+ for (const entry of raw.operations) {
72
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || !Array.isArray(entry[1]))
73
+ return null;
74
+ for (const receipt of entry[1]) {
75
+ if (receipt === null || typeof receipt !== "object" || typeof receipt.id !== "string" ||
76
+ typeof receipt.command !== "string" || typeof receipt.input !== "string" ||
77
+ !["applied", "rejected", "unknown-command"].includes(receipt.outcome) ||
78
+ (receipt.outcome === "rejected" && typeof receipt.reason !== "string"))
79
+ return null;
80
+ }
81
+ }
82
+ return raw;
83
+ },
84
+ };
85
+ runner.context().game.registerSave?.(sessionModule);
86
+ if (loaded !== null) {
87
+ if (Object.hasOwn(loaded.snapshot, "hostSession") && sessionModule.decode(loaded.snapshot["hostSession"]) === null) {
88
+ throw new Error("Invalid hosted player/retry state; save left untouched");
89
+ }
90
+ applyWorldSnapshot([sessionModule], loaded.snapshot);
91
+ }
38
92
  let savedRevision = loaded?.revision ?? 0;
93
+ let savedVersion = runner.context().version();
94
+ let savedSimTick = runner.context().sim.tick();
39
95
  let lastSaveAt = clock();
96
+ let writing = null;
97
+ let saveFailure = null;
40
98
  function persist() {
41
- savedRevision = runner.revision();
42
- store.save({ snapshot: runner.snapshot(), revision: savedRevision });
99
+ const record = { snapshot: runner.state(), revision: runner.revision() };
100
+ const version = runner.context().version();
101
+ const simTick = runner.context().sim.tick();
102
+ const success = () => {
103
+ savedRevision = record.revision;
104
+ savedVersion = version;
105
+ savedSimTick = simTick;
106
+ saveFailure = null;
107
+ };
108
+ const write = () => store.save(record);
109
+ let pending;
110
+ try {
111
+ pending = writing === null ? write() : writing.catch(() => { }).then(write);
112
+ }
113
+ catch (error) {
114
+ saveFailure = error;
115
+ throw error;
116
+ }
117
+ if (pending === undefined) {
118
+ success();
119
+ return;
120
+ }
121
+ const completion = pending.then(success, (error) => {
122
+ saveFailure = error;
123
+ throw error;
124
+ });
125
+ writing = completion;
126
+ void completion.catch(() => { }).finally(() => {
127
+ if (writing === completion)
128
+ writing = null;
129
+ });
130
+ return completion;
43
131
  }
44
132
  const pull = (sinceRevision) => {
45
- if (sinceRevision === null || sinceRevision <= 0) {
133
+ if (sinceRevision === null || sinceRevision <= (loaded?.revision ?? 0) || sinceRevision > runner.revision()) {
46
134
  return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
47
135
  }
48
136
  return { kind: "diff", diff: runner.diff(sinceRevision) };
49
137
  };
50
138
  return {
51
- join: (userId, isNew) => runner.join(userId, isNew),
52
- leave: (userId) => runner.leave(userId),
139
+ join(userId, isNew) {
140
+ runner.join(userId, knownPlayers.has(userId) ? false : isNew);
141
+ knownPlayers.add(userId);
142
+ runner.context().touch();
143
+ runner.commit();
144
+ },
145
+ hasPlayer: (userId) => knownPlayers.has(userId),
146
+ leave(userId) {
147
+ runner.leave(userId);
148
+ runner.commit();
149
+ },
53
150
  input: (userId, frame) => runner.input(userId, frame),
54
- command: (userId, name, input) => runner.command(userId, name, input),
151
+ command(userId, name, input, operationId) {
152
+ const envelope = unwrapOpEnvelope(input);
153
+ if (envelope !== null) {
154
+ operationId ??= envelope.opId;
155
+ input = envelope.input;
156
+ }
157
+ const receipts = operations.get(userId) ?? [];
158
+ const inputJson = JSON.stringify(input) ?? "null";
159
+ const receipt = operationId === undefined ? undefined : receipts.find((entry) => entry.id === operationId);
160
+ if (receipt !== undefined) {
161
+ if (receipt.command !== name || receipt.input !== inputJson)
162
+ return { status: "rejected", reason: "operation-conflict" };
163
+ if (receipt.outcome === "applied")
164
+ return { status: "applied", state: runner.context() };
165
+ if (receipt.outcome === "rejected")
166
+ return { status: "rejected", reason: receipt.reason };
167
+ return { status: "unknown-command" };
168
+ }
169
+ const before = runner.state();
170
+ let result;
171
+ try {
172
+ result = runner.command(userId, name, input);
173
+ }
174
+ catch (error) {
175
+ runner.context().restore(before);
176
+ throw error;
177
+ }
178
+ if (result.status !== "applied")
179
+ runner.context().restore(before);
180
+ if (operationId !== undefined) {
181
+ receipts.push({ id: operationId, command: name, input: inputJson, outcome: result.status,
182
+ ...(result.status === "rejected" ? { reason: result.reason } : {}) });
183
+ operations.set(userId, receipts.slice(-historyLimit));
184
+ runner.context().touch();
185
+ }
186
+ runner.commit();
187
+ return result;
188
+ },
55
189
  tick(dt) {
56
190
  const revision = runner.tick(dt);
57
- if (revision !== savedRevision) {
191
+ if (writing === null && (revision !== savedRevision || runner.context().version() !== savedVersion || runner.context().sim.tick() !== savedSimTick)) {
58
192
  const at = clock();
59
193
  if (saveIntervalMs <= 0 || at - lastSaveAt >= saveIntervalMs) {
60
194
  lastSaveAt = at;
61
- persist();
195
+ try {
196
+ persist();
197
+ }
198
+ catch { /* Exposed by persistenceError and retried by the next save. */ }
62
199
  }
63
200
  }
64
201
  return revision;
@@ -70,6 +207,7 @@ export function createHostedWorldSession(options) {
70
207
  revision: () => runner.revision(),
71
208
  members: () => runner.members(),
72
209
  save: persist,
210
+ persistenceError: () => saveFailure,
73
211
  runner: () => runner,
74
212
  };
75
213
  }
@@ -81,7 +219,7 @@ export async function createHostedWorldSessionAsync(options) {
81
219
  ...sessionOptions,
82
220
  store: {
83
221
  load: () => loaded,
84
- save: (record) => { void store.save(record); },
222
+ save: (record) => store.save(record),
85
223
  },
86
224
  });
87
225
  return session;
@@ -13,8 +13,8 @@ export interface HostedWorldStore {
13
13
  load(): Promise<HostedWorldRecord | null>;
14
14
  save(record: HostedWorldRecord): Promise<void>;
15
15
  }
16
- /** Synchronous store adapter retained for deterministic in-process tests. */
16
+ /** Already-loaded store adapter: synchronous reads, with synchronous or asynchronous writes. */
17
17
  export interface SyncHostedWorldStore {
18
18
  load(): HostedWorldRecord | null;
19
- save(record: HostedWorldRecord): void;
19
+ save(record: HostedWorldRecord): void | Promise<void>;
20
20
  }
@@ -10,7 +10,7 @@ export interface InputRecorderState {
10
10
  }
11
11
  /** Tick-indexed input log: record per tick, look up the frame in force at any tick. */
12
12
  export interface InputRecorder {
13
- /** Store the frame in force from `tick` onward. Recording the same tick twice replaces the earlier frame. */
13
+ /** Store held state from `tick` onward and presses on that tick only. Same-tick recordings merge press identities while replacing continuous state. */
14
14
  record(tick: number, frame: InputFrame): void;
15
15
  /** The frame in force at `tick`: the latest recorded frame whose tick is at most `tick`, or `null` before the first. */
16
16
  frameAt(tick: number): InputFrame | null;
@@ -2,6 +2,7 @@ function cloneFrame(frame) {
2
2
  return {
3
3
  held: [...frame.held],
4
4
  pointer: frame.pointer === null ? null : { ...frame.pointer },
5
+ ...(frame.presses === undefined ? {} : { presses: frame.presses.map(press => ({ ...press })) }),
5
6
  ...(frame.analog === undefined ? {} : { analog: frame.analog === null ? null : { ...frame.analog } }),
6
7
  ...(frame.tick === undefined ? {} : { tick: frame.tick }),
7
8
  };
@@ -14,6 +15,7 @@ function cloneFrame(frame) {
14
15
  */
15
16
  export function createInputRecorder() {
16
17
  let frames = [];
18
+ const continuousFrames = new WeakMap();
17
19
  function indexAtOrBefore(tick) {
18
20
  let lo = 0;
19
21
  let hi = frames.length - 1;
@@ -35,6 +37,13 @@ export function createInputRecorder() {
35
37
  const entry = { tick, frame: cloneFrame(frame) };
36
38
  const index = indexAtOrBefore(tick);
37
39
  if (index >= 0 && frames[index].tick === tick) {
40
+ const previous = frames[index].frame.presses;
41
+ if (previous !== undefined) {
42
+ const presses = new Map(previous.map(press => [press.seq, press]));
43
+ for (const press of entry.frame.presses ?? [])
44
+ presses.set(press.seq, press);
45
+ entry.frame.presses = [...presses.values()].map(press => ({ ...press }));
46
+ }
38
47
  frames[index] = entry;
39
48
  return;
40
49
  }
@@ -42,7 +51,18 @@ export function createInputRecorder() {
42
51
  },
43
52
  frameAt(tick) {
44
53
  const index = indexAtOrBefore(tick);
45
- return index < 0 ? null : frames[index].frame;
54
+ if (index < 0)
55
+ return null;
56
+ const entry = frames[index];
57
+ if (entry.tick === tick || entry.frame.presses === undefined)
58
+ return entry.frame;
59
+ let heldFrame = continuousFrames.get(entry.frame);
60
+ if (heldFrame === undefined) {
61
+ const { presses: _presses, ...continuous } = entry.frame;
62
+ heldFrame = continuous;
63
+ continuousFrames.set(entry.frame, heldFrame);
64
+ }
65
+ return heldFrame;
46
66
  },
47
67
  frames: () => frames,
48
68
  lastTick: () => (frames.length === 0 ? -1 : frames[frames.length - 1].tick),
@@ -1,10 +1,17 @@
1
1
  import type { PointerAxisState } from "../input/pointerAxis.js";
2
2
  import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
3
3
  import { type HapticChannels } from "../input/haptics.js";
4
- /** One client's input for a tick — the semantic held-action set plus pointer state, the serializable, over-the-wire counterpart of {@link InputSnapshot} the host stores per connected player. */
4
+ /** One discrete press, identified across retransmitted live input frames. */
5
+ export interface InputPress {
6
+ action: string;
7
+ seq: number;
8
+ }
9
+ /** One client's serializable held state and discrete edges for a simulation tick. */
5
10
  export interface InputFrame {
6
11
  held: readonly string[];
7
12
  pointer: PointerAxisState | null;
13
+ /** Discrete presses retained through release until the host accepts them. Each identity applies once, on its recorded simulation tick. */
14
+ presses?: readonly InputPress[];
8
15
  /** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
9
16
  analog?: Readonly<Record<string, number>> | null;
10
17
  /** Simulation tick (`ctx.sim.tick()`) the frame was captured for; hosts and replay align inputs on it. Absent from senders that predate the fixed-step loop. */
@@ -22,8 +29,13 @@ export interface InputSnapshot {
22
29
  * and the shell mixes them by priority onto that user's pad. Local only — never in a snapshot or on the wire.
23
30
  */
24
31
  haptics(userId: string): HapticChannels;
25
- /** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
26
- publish(held: readonly string[]): void;
32
+ /** Replaces held state without notifying subscribers. Supplying `edges` buffers short presses until `beginStep`; `reset` discards the current owner's pending edges. Without `edges`, legacy publication-based edge detection is preserved. */
33
+ publish(held: readonly string[], edges?: {
34
+ pressed?: readonly string[];
35
+ reset?: boolean;
36
+ }): void;
37
+ /** Sample buffered input once before a simulation step. Zero-step render frames retain presses; subsequent steps retire the sampled edges. */
38
+ beginStep(): void;
27
39
  /** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
28
40
  publishPointer(state: PointerAxisState | null): void;
29
41
  /** Replaces the analog per-action magnitudes for this frame (#1370) — the virtual joystick's continuous vector, split into 0..1 values per movement action. `null` clears back to digital-only. Same no-notify contract as `publish`. */
@@ -31,7 +43,7 @@ export interface InputSnapshot {
31
43
  isDown(action: string): boolean;
32
44
  /** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
33
45
  held(): readonly string[];
34
- /** True only on the frame `action` transitions from up to down (#671) — derived from the last two published held sets, so it stays replay-safe. */
46
+ /** True on the sampled simulation step containing a press; legacy publishers use the last two held sets. */
35
47
  justPressed(action: string): boolean;
36
48
  /** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
37
49
  justReleased(action: string): boolean;
@@ -6,6 +6,11 @@ export function createInputSnapshot() {
6
6
  let heldList = [];
7
7
  let pointerState = null;
8
8
  let analogValues = null;
9
+ let buffered = false;
10
+ let stepHeld = new Set();
11
+ const pendingPressed = new Set();
12
+ let stepPressed = new Set();
13
+ let stepReleased = new Set();
9
14
  const haptics = new Map();
10
15
  const value = (action) => {
11
16
  const analog = analogValues?.[action];
@@ -23,10 +28,33 @@ export function createInputSnapshot() {
23
28
  }
24
29
  return channels;
25
30
  },
26
- publish(held) {
31
+ publish(held, edges) {
27
32
  previousHeldSet = heldSet;
28
33
  heldList = Object.freeze([...held]);
29
34
  heldSet = new Set(held);
35
+ if (edges !== undefined) {
36
+ buffered = true;
37
+ if (edges.reset) {
38
+ pendingPressed.clear();
39
+ stepPressed.clear();
40
+ stepReleased.clear();
41
+ stepHeld = new Set(held);
42
+ }
43
+ else {
44
+ for (const action of edges.pressed ?? [])
45
+ pendingPressed.add(action);
46
+ }
47
+ }
48
+ },
49
+ beginStep() {
50
+ buffered = true;
51
+ stepPressed = new Set(pendingPressed);
52
+ pendingPressed.clear();
53
+ for (const action of heldSet)
54
+ if (!stepHeld.has(action))
55
+ stepPressed.add(action);
56
+ stepReleased = new Set([...stepHeld].filter(action => !heldSet.has(action)));
57
+ stepHeld = new Set(heldSet);
30
58
  },
31
59
  publishPointer(state) {
32
60
  pointerState = state === null ? null : Object.freeze({ ...state });
@@ -40,7 +68,7 @@ export function createInputSnapshot() {
40
68
  value,
41
69
  analog: () => analogValues,
42
70
  axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges, value),
43
- justPressed: (action) => heldSet.has(action) && !previousHeldSet.has(action),
44
- justReleased: (action) => !heldSet.has(action) && previousHeldSet.has(action),
71
+ justPressed: (action) => buffered ? stepPressed.has(action) : heldSet.has(action) && !previousHeldSet.has(action),
72
+ justReleased: (action) => buffered ? stepReleased.has(action) : !heldSet.has(action) && previousHeldSet.has(action),
45
73
  };
46
74
  }
@@ -2,7 +2,7 @@ import { type SaveBackend, type SaveStatus, type SaveTimers } from "../game/save
2
2
  import type { WorldSnapshot } from "./worldSnapshot.js";
3
3
  /**
4
4
  * The narrow slice of a live `GameContext` a runtime save reads and writes — a
5
- * `GameContext` satisfies it directly (`ctx.snapshot`/`ctx.hydrate`/`ctx.subscribe`).
5
+ * `GameContext` satisfies it directly (`ctx.state`/`ctx.restore`/`ctx.subscribe`).
6
6
  * Depending on this instead of the full context keeps the save controller a deep,
7
7
  * decoupled module: it captures and restores the *whole* opted-in world without
8
8
  * knowing any subsystem.
@@ -10,6 +10,10 @@ import type { WorldSnapshot } from "./worldSnapshot.js";
10
10
  export interface RuntimeSaveTarget {
11
11
  snapshot(): WorldSnapshot;
12
12
  hydrate(snapshot: WorldSnapshot): void;
13
+ /** Complete authoritative capture when the target distinguishes persistence from replication. */
14
+ state?(): WorldSnapshot;
15
+ /** Authoritative inverse of `state`; used together with it instead of replication hydration. */
16
+ restore?(state: WorldSnapshot): void;
13
17
  subscribe(listener: () => void): () => void;
14
18
  }
15
19
  /** `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
@@ -39,8 +43,8 @@ export interface RuntimeSaveConfig {
39
43
  export type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target">;
40
44
  /**
41
45
  * Whole-world save/load bound to a live world and a pluggable backend. `save()`
42
- * captures `target.snapshot()` and writes it; `load()` reads it back and
43
- * `target.hydrate()`s the whole world. In `autosave` mode it also writes on a
46
+ * captures `target.state()` and writes it; `load()` reads it back and
47
+ * calls `target.restore()`. Older targets use `snapshot()`/`hydrate()`. In `autosave` mode it also writes on a
44
48
  * trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud
45
49
  * (backend swap) all come for free from the underlying save store.
46
50
  */
@@ -51,13 +55,13 @@ export interface RuntimeSave {
51
55
  save(): Promise<void>;
52
56
  /** Semantic alias for `save()` — call it from a save point, a quest-complete or area-enter handler. */
53
57
  checkpoint(): Promise<void>;
54
- /** Load the active slot and hydrate the whole world; resolves `true` when a real save was applied, `false` for an empty slot. */
58
+ /** Load the active slot and hydrate the whole world; resolves `true` when a real save was applied, `false` for an empty slot or failed payload read. Failed reads never restore cached state. */
55
59
  load(): Promise<boolean>;
56
- /** Whether the active slot holds a saved world — gate a title-screen "Continue" on it. */
60
+ /** Whether the active slot holds a saved world — gate a title-screen "Continue" on it. Failed payload reads resolve false and report error status. */
57
61
  hasSave(): Promise<boolean>;
58
62
  /** Delete the active slot's save. */
59
63
  clear(): Promise<void>;
60
- /** Switch slot and load it, hydrating the world when that slot has a save. */
64
+ /** Switch slot and load it, hydrating the world when that slot has a save. Failed payload reads resolve false without restoring cached state. */
61
65
  switchSlot(slot: string): Promise<boolean>;
62
66
  /** The slots this game has written — back a load/save menu. */
63
67
  slots(): Promise<string[]>;
@@ -48,7 +48,7 @@ export function createRuntimeSave(config) {
48
48
  }
49
49
  function persist() {
50
50
  clearTimer();
51
- store.set(config.target.snapshot());
51
+ store.set(config.target.state?.() ?? config.target.snapshot());
52
52
  return store.save();
53
53
  }
54
54
  /**
@@ -77,7 +77,10 @@ export function createRuntimeSave(config) {
77
77
  return false;
78
78
  restoring = true;
79
79
  try {
80
- config.target.hydrate(snapshot);
80
+ if (config.target.restore !== undefined)
81
+ config.target.restore(snapshot);
82
+ else
83
+ config.target.hydrate(snapshot);
81
84
  }
82
85
  finally {
83
86
  restoring = false;
@@ -91,15 +94,18 @@ export function createRuntimeSave(config) {
91
94
  save,
92
95
  checkpoint: save,
93
96
  async load() {
94
- return applyLoaded(await store.load());
97
+ const snapshot = await store.load();
98
+ return store.status() !== "error" && applyLoaded(snapshot);
95
99
  },
96
100
  async hasSave() {
97
- return hasContent(await store.load());
101
+ const snapshot = await store.load();
102
+ return store.status() !== "error" && hasContent(snapshot);
98
103
  },
99
104
  clear: () => store.clear(),
100
105
  async switchSlot(slot) {
101
106
  clearTimer();
102
- return applyLoaded(await store.switchSlot(slot));
107
+ const snapshot = await store.switchSlot(slot);
108
+ return store.status() !== "error" && applyLoaded(snapshot);
103
109
  },
104
110
  slots: () => store.slots(),
105
111
  subscribe: (listener) => store.subscribe(listener),
@@ -1,4 +1,5 @@
1
1
  import type { EntityStore } from "../scene/entityStore.js";
2
+ import type { SimClock } from "../time/simClock.js";
2
3
  import { type PoseHistory, type RenderPose } from "./poseInterpolation.js";
3
4
  import { type SimAdvanceResult, type SimLoop, type SimulationConfig } from "./simLoop.js";
4
5
  /** Where a registered stage runs inside one simulation step. */
@@ -7,7 +8,8 @@ export type SimStagePhase = "beforeMovement" | "afterMovement" | "afterTick";
7
8
  export interface SimStage {
8
9
  id: string;
9
10
  phase: SimStagePhase;
10
- run(dt: number, tick: number): void;
11
+ /** dt is real seconds; gameDt is the scaled authoritative game-seconds for this step. */
12
+ run(dt: number, tick: number, gameDt: number): void;
11
13
  }
12
14
  /** Serializable `ctx.sim` state: loop position plus the interpolation pose history. */
13
15
  export interface SimContextState {
@@ -21,8 +23,10 @@ export interface SimContextState {
21
23
  */
22
24
  export interface SimContext {
23
25
  readonly loop: SimLoop;
24
- /** Advance the loop; `body` runs once per simulation step with that step's dt. Pose history is captured before each step. */
25
- advance(realDt: number, body: (dt: number, tick: number) => void): SimAdvanceResult;
26
+ /** Advance simulation and its game clock once per step. The callback receives real dt, tick and scaled game dt. Prediction can opt out of clock advancement. */
27
+ advance(realDt: number, body: (dt: number, tick: number, gameDt: number) => void, options?: {
28
+ advanceTime?: boolean;
29
+ }): SimAdvanceResult;
26
30
  tick(): number;
27
31
  /** Interpolation fraction for the current frame, 1 on a variable loop. */
28
32
  alpha(): number;
@@ -42,4 +46,5 @@ export interface SimContext {
42
46
  export declare function createSimContext(options: {
43
47
  config?: SimulationConfig;
44
48
  entities: EntityStore;
49
+ time?: Pick<SimClock, "advance">;
45
50
  }): SimContext;
@@ -7,9 +7,10 @@ export function createSimContext(options) {
7
7
  const poses = createPoseHistory({ snapDistance: loop.config().snapDistance });
8
8
  const stages = new Map();
9
9
  let advancing = false;
10
+ let currentGameDt = 0;
10
11
  return {
11
12
  loop,
12
- advance(realDt, body) {
13
+ advance(realDt, body, stepOptions) {
13
14
  if (advancing)
14
15
  throw new Error("ctx.sim.advance is not reentrant");
15
16
  advancing = true;
@@ -17,11 +18,14 @@ export function createSimContext(options) {
17
18
  return loop.advance(realDt, (dt, tick) => {
18
19
  if (loop.isFixed())
19
20
  poses.beginStep(entities);
20
- body(dt, tick);
21
+ const gameDt = stepOptions?.advanceTime === false ? 0 : options.time?.advance(dt) ?? dt;
22
+ currentGameDt = gameDt;
23
+ body(dt, tick, gameDt);
21
24
  });
22
25
  }
23
26
  finally {
24
27
  advancing = false;
28
+ currentGameDt = 0;
25
29
  }
26
30
  },
27
31
  tick: () => loop.tick(),
@@ -49,7 +53,7 @@ export function createSimContext(options) {
49
53
  return;
50
54
  const tick = loop.tick();
51
55
  for (const stage of list)
52
- stage.run(dt, tick);
56
+ stage.run(dt, tick, advancing ? currentGameDt : dt);
53
57
  },
54
58
  retune(config) {
55
59
  loop.retune(config);
@@ -47,6 +47,8 @@ export function createContextSimSnapshot(ctx) {
47
47
  capture: () => structuredClone(ctx.snapshot()),
48
48
  restore: (data) => ctx.hydrate(structuredClone(data)),
49
49
  });
50
+ registry.register({ id: "projectiles", capture: ctx.scene.entity.projectileState, restore: ctx.scene.entity.restoreProjectiles });
51
+ registry.register({ id: "environment", capture: ctx.environment.snapshot, restore: ctx.environment.restore });
50
52
  registry.register({ id: "clock", capture: () => ctx.time.snapshot(), restore: (data) => ctx.time.hydrate(data) });
51
53
  registry.register({ id: "sim", capture: () => ctx.sim.snapshot(), restore: (data) => ctx.sim.restore(data) });
52
54
  if (rngStateOf(ctx.rng) !== null) {
@@ -35,6 +35,8 @@ export interface WorldDiff {
35
35
  */
36
36
  export interface WorldReplicatorOptions {
37
37
  worldVersion?: () => number;
38
+ /** Cursor loaded with a persisted baseline; new commits continue above it. Default `0`. */
39
+ initialRevision?: number;
38
40
  }
39
41
  /**
40
42
  * Turns successive full {@link WorldSnapshot}s into per-client {@link WorldDiff}s. Each `commit()` re-reads the
@@ -10,7 +10,7 @@ const CORE_KEYS = new Set(["entities", "stats", "store"]);
10
10
  export function createWorldReplicator(takeSnapshot, options = {}) {
11
11
  const worldVersion = options.worldVersion;
12
12
  let committedVersion;
13
- let revision = 0;
13
+ let revision = options.initialRevision ?? 0;
14
14
  const entities = new Map();
15
15
  const removedEntities = new Map();
16
16
  const stats = new Map();