@jgengine/core 0.18.0 → 0.18.1

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 (174) hide show
  1. package/CHANGELOG.md +114 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +85 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +12 -7
  90. package/dist/runtime/commandRunner.js +7 -5
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +20 -4
  94. package/dist/runtime/gameRuntime.js +17 -4
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +32 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +94 -19
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
@@ -1,6 +1,7 @@
1
1
  import { resolvePlayerMovementTuning, stepPlayerMovement, } from "../movement/playerMovement.js";
2
2
  import { advanceBehaviors } from "../scene/behaviorRuntime.js";
3
3
  import { createGameContext } from "./gameContext.js";
4
+ import { createContextSimSnapshot } from "./simSnapshot.js";
4
5
  export function createHeadlessRunner(options) {
5
6
  const { definition, loop } = options;
6
7
  const content = options.content ?? {};
@@ -15,8 +16,13 @@ export function createHeadlessRunner(options) {
15
16
  });
16
17
  loop?.onInit?.(ctx);
17
18
  loop?.onNewPlayer?.(ctx);
19
+ const simSnapshot = createContextSimSnapshot(ctx);
18
20
  const tuning = options.playerMovement === true
19
- ? resolvePlayerMovementTuning({ physics: definition.physics, world: definition.world })
21
+ ? resolvePlayerMovementTuning({
22
+ physics: definition.physics,
23
+ world: definition.world,
24
+ movement: options.movement,
25
+ })
20
26
  : null;
21
27
  function publishInput(input) {
22
28
  if (input.held !== undefined)
@@ -47,13 +53,25 @@ export function createHeadlessRunner(options) {
47
53
  if (input !== undefined)
48
54
  publishInput(input);
49
55
  const dt = Math.min(dtSeconds, maxStep);
50
- const gameDt = ctx.time.advance(dt);
51
- if (tuning !== null) {
52
- stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, dt, tuning, options.heading);
53
- }
54
- loop?.onTick?.(ctx, gameDt);
55
- advanceBehaviors(ctx, gameDt);
56
- return gameDt;
56
+ let total = 0;
57
+ ctx.sim.advance(dt, (stepDt) => {
58
+ const gameDt = ctx.time.advance(stepDt);
59
+ total += gameDt;
60
+ 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);
63
+ }
64
+ ctx.sim.runStages("afterMovement", stepDt);
65
+ loop?.onTick?.(ctx, gameDt);
66
+ advanceBehaviors(ctx, gameDt);
67
+ ctx.sim.runStages("afterTick", stepDt);
68
+ });
69
+ return total;
70
+ },
71
+ snapshot: () => simSnapshot.capture(),
72
+ restore(snapshot) {
73
+ simSnapshot.restore(snapshot);
57
74
  },
75
+ tick: () => ctx.sim.tick(),
58
76
  };
59
77
  }
@@ -1,6 +1,6 @@
1
1
  import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
2
2
  /**
3
- * Hard ceiling on `slotsPerServer` for a single hosted server row.
3
+ * Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
4
4
  *
5
5
  * A hosted server keeps its whole roster, every member's session state, and the world row in one
6
6
  * backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
@@ -10,7 +10,7 @@ import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
10
10
  */
11
11
  export declare const JG_MAX_MEMBERS_PER_SERVER = 256;
12
12
  /** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
13
- export declare function validateSlotsPerServer(slotsPerServer: number): {
13
+ export declare function validateSlotsPerServer(slotsPerServer: number, topology?: "rooms" | "shared"): {
14
14
  ok: true;
15
15
  } | {
16
16
  ok: false;
@@ -75,6 +75,7 @@ export declare function isServerMember(memberUserIds: readonly string[], userId:
75
75
  * True when the server has no free slots for a non-member. Existing members never count as
76
76
  * "full" so rejoin/leave cycles keep working.
77
77
  */
78
+ export declare function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): boolean;
78
79
  export declare function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean;
79
80
  /**
80
81
  * Whether a private-visibility server blocks this join (non-member without a matching code).
@@ -1,6 +1,6 @@
1
1
  import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
2
2
  /**
3
- * Hard ceiling on `slotsPerServer` for a single hosted server row.
3
+ * Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
4
4
  *
5
5
  * A hosted server keeps its whole roster, every member's session state, and the world row in one
6
6
  * backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
@@ -10,11 +10,11 @@ import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
10
10
  */
11
11
  export const JG_MAX_MEMBERS_PER_SERVER = 256;
12
12
  /** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
13
- export function validateSlotsPerServer(slotsPerServer) {
14
- if (!Number.isInteger(slotsPerServer) || slotsPerServer < 1) {
13
+ export function validateSlotsPerServer(slotsPerServer, topology = "rooms") {
14
+ if (!Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) {
15
15
  return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` };
16
16
  }
17
- if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
17
+ if (topology !== "shared" && slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
18
18
  return {
19
19
  ok: false,
20
20
  reason: `slotsPerServer ${slotsPerServer} exceeds JG_MAX_MEMBERS_PER_SERVER (${JG_MAX_MEMBERS_PER_SERVER}). ` +
@@ -76,12 +76,10 @@ export function canJoinPrivateServer(args) {
76
76
  export function isServerMember(memberUserIds, userId) {
77
77
  return memberUserIds.includes(userId);
78
78
  }
79
- /**
80
- * True when the server has no free slots for a non-member. Existing members never count as
81
- * "full" so rejoin/leave cycles keep working.
82
- */
83
- export function isServerFull(memberUserIds, slotsPerServer, userId) {
84
- return memberUserIds.length >= slotsPerServer && !memberUserIds.includes(userId);
79
+ export function isServerFull(members, slotsPerServer, member) {
80
+ const count = typeof members === "number" ? members : members.length;
81
+ const isMember = typeof member === "boolean" ? member : typeof members !== "number" && members.includes(member);
82
+ return count >= slotsPerServer && !isMember;
85
83
  }
86
84
  /**
87
85
  * Whether a private-visibility server blocks this join (non-member without a matching code).
@@ -2,6 +2,9 @@ import { syncLifecyclePhase } from "../game/gamePhase.js";
2
2
  import { advanceBehaviors } from "../scene/behaviorRuntime.js";
3
3
  import { createGameContext } from "./gameContext.js";
4
4
  import {} from "./inputSnapshot.js";
5
+ import { serverStep } from "../movement/serverStep.js";
6
+ import { resolvePlayerMovementTuning } from "../movement/playerMovement.js";
7
+ import { createInputRecorder } from "./inputRecorder.js";
5
8
  import { createWorldReplicator } from "./worldReplication.js";
6
9
  /** Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire. */
7
10
  export const INPUT_COMMAND = "engine.input";
@@ -24,7 +27,10 @@ export function createHostedGameRunner(options) {
24
27
  });
25
28
  const members = new Map();
26
29
  const inputs = new Map();
30
+ const latestInputs = new Map();
31
+ const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics });
27
32
  const inputSeq = new Map();
33
+ let hostTick = 0;
28
34
  loop.onInit?.(ctx);
29
35
  syncLifecyclePhase(ctx, definition.lifecycle);
30
36
  if (restore !== undefined)
@@ -49,6 +55,7 @@ export function createHostedGameRunner(options) {
49
55
  return;
50
56
  members.delete(userId);
51
57
  inputs.delete(userId);
58
+ latestInputs.delete(userId);
52
59
  inputSeq.delete(userId);
53
60
  loop.onPlayerLeave?.(ctx, player);
54
61
  ctx.game.players?.leave(userId);
@@ -61,24 +68,45 @@ export function createHostedGameRunner(options) {
61
68
  return;
62
69
  inputSeq.set(userId, seq);
63
70
  }
64
- inputs.set(userId, frame);
65
- ctx.game.players?.setInput(userId, frame);
71
+ let recorder = inputs.get(userId);
72
+ if (recorder === undefined) {
73
+ recorder = createInputRecorder();
74
+ inputs.set(userId, recorder);
75
+ }
76
+ recorder.record(frame.tick ?? ctx.sim.tick() + 1, frame);
77
+ latestInputs.set(userId, frame);
66
78
  },
67
79
  heldInput(userId) {
68
- return inputs.get(userId) ?? null;
80
+ return latestInputs.get(userId) ?? null;
69
81
  },
70
82
  command(userId, name, input) {
71
83
  return ctx.game.commands.runAs(userId, name, input);
72
84
  },
73
85
  tick(dt) {
74
- loop.onTick?.(ctx, dt);
75
- advanceBehaviors(ctx, dt);
86
+ hostTick += 1;
87
+ ctx.sim.advance(dt, (stepDt, tick) => {
88
+ for (const [userId, recorder] of inputs) {
89
+ const frame = recorder.frameAt(tick);
90
+ if (frame !== null)
91
+ ctx.game.players?.setInput(userId, frame);
92
+ }
93
+ ctx.sim.runStages("beforeMovement", stepDt);
94
+ for (const userId of members.keys()) {
95
+ const frame = inputs.get(userId)?.frameAt(tick) ?? null;
96
+ if (frame !== null)
97
+ serverStep(ctx, userId, frame, stepDt, movementTuning);
98
+ }
99
+ ctx.sim.runStages("afterMovement", stepDt);
100
+ loop.onTick?.(ctx, stepDt);
101
+ advanceBehaviors(ctx, stepDt);
102
+ ctx.sim.runStages("afterTick", stepDt);
103
+ });
76
104
  syncLifecyclePhase(ctx, definition.lifecycle);
77
105
  return replicator.commit();
78
106
  },
79
107
  diff: (sinceRevision) => replicator.diff(sinceRevision),
80
108
  revision: () => replicator.revision(),
81
- snapshot: (viewer) => ctx.snapshot(viewer),
109
+ snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }),
82
110
  projectsViewers: () => ctx.replicatesPerViewer(),
83
111
  members: () => Array.from(members.keys()),
84
112
  context: () => ctx,
@@ -5,24 +5,14 @@ import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
5
5
  import { type HostedGameRunner, type InputFrame } from "./hostedGameRunner.js";
6
6
  import type { WorldDiff } from "./worldReplication.js";
7
7
  import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
8
- /** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */
9
- export interface HostedWorldRecord {
10
- snapshot: WorldSnapshot;
11
- revision: number;
12
- }
13
- /**
14
- * Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
15
- * Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
16
- * saves on a cadence; a stateless host reconstructs from `load()` each invocation.
17
- */
18
- export interface HostedWorldStore {
19
- load(): HostedWorldRecord | null;
20
- save(record: HostedWorldRecord): void;
21
- }
8
+ import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
9
+ export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
22
10
  /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host.
23
11
  * @internal
24
12
  */
25
- export declare function memoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore;
13
+ export declare function memoryWorldStore(seed?: HostedWorldRecord): SyncHostedWorldStore;
14
+ /** Async in-process store for callers exercising the production persistence contract. */
15
+ export declare function asyncMemoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore;
26
16
  /** A client replication pull: a full baseline (first sync / fell behind) or a diff since the client's cursor. */
27
17
  export type HostedWorldSync = {
28
18
  kind: "baseline";
@@ -39,7 +29,7 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
39
29
  host?: LoopPlayer;
40
30
  now?: () => number;
41
31
  /** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
42
- store?: HostedWorldStore;
32
+ store?: SyncHostedWorldStore;
43
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. */
44
34
  saveIntervalMs?: number;
45
35
  /** Render-model lookup for collider auto-fit, forwarded to the runner — see {@link HostedGameRunnerOptions.models}. */
@@ -59,6 +49,8 @@ export interface HostedWorldSession {
59
49
  tick(dt: number): number;
60
50
  /** Replication pull: `null`/`0` cursor → full baseline; a prior revision → a diff since it. */
61
51
  sync(sinceRevision: number | null): HostedWorldSync;
52
+ /** Alias used by host transports when pulling the next baseline or diff for a subscriber. */
53
+ pull(sinceRevision: number | null): HostedWorldSync;
62
54
  /** The full world baseline projected for one viewer (private/AOI). Identity when no replication policy is set. */
63
55
  snapshotFor(viewer: SnapshotViewer): WorldSnapshot;
64
56
  /** True when {@link snapshotFor} is viewer-dependent — a host must serve each connection its own projected frame instead of a shared diff. */
@@ -73,3 +65,7 @@ export interface HostedWorldSession {
73
65
  * @internal
74
66
  */
75
67
  export declare function createHostedWorldSession<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedWorldSessionOptions<TAssetRef, TMultiplayer>): HostedWorldSession;
68
+ /** Build a hosted session from an asynchronous persistence backend. */
69
+ export declare function createHostedWorldSessionAsync<TAssetRef extends ModelAssetRef, TMultiplayer>(options: Omit<HostedWorldSessionOptions<TAssetRef, TMultiplayer>, "store"> & {
70
+ store: HostedWorldStore;
71
+ }): Promise<HostedWorldSession>;
@@ -11,6 +11,14 @@ export function memoryWorldStore(seed) {
11
11
  },
12
12
  };
13
13
  }
14
+ /** Async in-process store for callers exercising the production persistence contract. */
15
+ export function asyncMemoryWorldStore(seed) {
16
+ let record = seed ?? null;
17
+ return {
18
+ async load() { return record; },
19
+ async save(next) { record = { snapshot: next.snapshot, revision: next.revision }; },
20
+ };
21
+ }
14
22
  /** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
15
23
  * @internal
16
24
  */
@@ -33,6 +41,12 @@ export function createHostedWorldSession(options) {
33
41
  savedRevision = runner.revision();
34
42
  store.save({ snapshot: runner.snapshot(), revision: savedRevision });
35
43
  }
44
+ const pull = (sinceRevision) => {
45
+ if (sinceRevision === null || sinceRevision <= 0) {
46
+ return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
47
+ }
48
+ return { kind: "diff", diff: runner.diff(sinceRevision) };
49
+ };
36
50
  return {
37
51
  join: (userId, isNew) => runner.join(userId, isNew),
38
52
  leave: (userId) => runner.leave(userId),
@@ -49,12 +63,8 @@ export function createHostedWorldSession(options) {
49
63
  }
50
64
  return revision;
51
65
  },
52
- sync(sinceRevision) {
53
- if (sinceRevision === null || sinceRevision <= 0) {
54
- return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
55
- }
56
- return { kind: "diff", diff: runner.diff(sinceRevision) };
57
- },
66
+ pull,
67
+ sync: pull,
58
68
  snapshotFor: (viewer) => runner.snapshot(viewer),
59
69
  projectsViewers: () => runner.projectsViewers(),
60
70
  revision: () => runner.revision(),
@@ -63,3 +73,16 @@ export function createHostedWorldSession(options) {
63
73
  runner: () => runner,
64
74
  };
65
75
  }
76
+ /** Build a hosted session from an asynchronous persistence backend. */
77
+ export async function createHostedWorldSessionAsync(options) {
78
+ const { store, ...sessionOptions } = options;
79
+ const loaded = await store.load();
80
+ const session = createHostedWorldSession({
81
+ ...sessionOptions,
82
+ store: {
83
+ load: () => loaded,
84
+ save: (record) => { void store.save(record); },
85
+ },
86
+ });
87
+ return session;
88
+ }
@@ -0,0 +1,20 @@
1
+ import type { WorldSnapshot } from "./worldSnapshot.js";
2
+ /** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */
3
+ export interface HostedWorldRecord {
4
+ snapshot: WorldSnapshot;
5
+ revision: number;
6
+ }
7
+ /**
8
+ * Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
9
+ * Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
10
+ * saves on a cadence; a stateless host reconstructs from `load()` each invocation.
11
+ */
12
+ export interface HostedWorldStore {
13
+ load(): Promise<HostedWorldRecord | null>;
14
+ save(record: HostedWorldRecord): Promise<void>;
15
+ }
16
+ /** Synchronous store adapter retained for deterministic in-process tests. */
17
+ export interface SyncHostedWorldStore {
18
+ load(): HostedWorldRecord | null;
19
+ save(record: HostedWorldRecord): void;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { InputFrame } from "./inputSnapshot.js";
2
+ /** An input frame and the first tick it applies to. */
3
+ export interface RecordedInput {
4
+ tick: number;
5
+ frame: InputFrame;
6
+ }
7
+ /** Serializable recorder contents, sorted by tick. */
8
+ export interface InputRecorderState {
9
+ frames: RecordedInput[];
10
+ }
11
+ /** Tick-indexed input log: record per tick, look up the frame in force at any tick. */
12
+ export interface InputRecorder {
13
+ /** Store the frame in force from `tick` onward. Recording the same tick twice replaces the earlier frame. */
14
+ record(tick: number, frame: InputFrame): void;
15
+ /** The frame in force at `tick`: the latest recorded frame whose tick is at most `tick`, or `null` before the first. */
16
+ frameAt(tick: number): InputFrame | null;
17
+ frames(): readonly RecordedInput[];
18
+ /** Highest recorded tick, or `-1` when empty. */
19
+ lastTick(): number;
20
+ clear(): void;
21
+ snapshot(): InputRecorderState;
22
+ restore(state: InputRecorderState): void;
23
+ }
24
+ /**
25
+ * Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
26
+ * into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
27
+ *
28
+ * @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
29
+ */
30
+ export declare function createInputRecorder(): InputRecorder;
@@ -0,0 +1,59 @@
1
+ function cloneFrame(frame) {
2
+ return {
3
+ held: [...frame.held],
4
+ pointer: frame.pointer === null ? null : { ...frame.pointer },
5
+ ...(frame.analog === undefined ? {} : { analog: frame.analog === null ? null : { ...frame.analog } }),
6
+ ...(frame.tick === undefined ? {} : { tick: frame.tick }),
7
+ };
8
+ }
9
+ /**
10
+ * Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
11
+ * into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
12
+ *
13
+ * @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
14
+ */
15
+ export function createInputRecorder() {
16
+ let frames = [];
17
+ function indexAtOrBefore(tick) {
18
+ let lo = 0;
19
+ let hi = frames.length - 1;
20
+ let found = -1;
21
+ while (lo <= hi) {
22
+ const mid = (lo + hi) >> 1;
23
+ if (frames[mid].tick <= tick) {
24
+ found = mid;
25
+ lo = mid + 1;
26
+ }
27
+ else {
28
+ hi = mid - 1;
29
+ }
30
+ }
31
+ return found;
32
+ }
33
+ return {
34
+ record(tick, frame) {
35
+ const entry = { tick, frame: cloneFrame(frame) };
36
+ const index = indexAtOrBefore(tick);
37
+ if (index >= 0 && frames[index].tick === tick) {
38
+ frames[index] = entry;
39
+ return;
40
+ }
41
+ frames.splice(index + 1, 0, entry);
42
+ },
43
+ frameAt(tick) {
44
+ const index = indexAtOrBefore(tick);
45
+ return index < 0 ? null : frames[index].frame;
46
+ },
47
+ frames: () => frames,
48
+ lastTick: () => (frames.length === 0 ? -1 : frames[frames.length - 1].tick),
49
+ clear() {
50
+ frames = [];
51
+ },
52
+ snapshot: () => ({ frames: frames.map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) })) }),
53
+ restore(state) {
54
+ frames = state.frames
55
+ .map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) }))
56
+ .sort((a, b) => a.tick - b.tick);
57
+ },
58
+ };
59
+ }
@@ -6,8 +6,16 @@ export interface InputFrame {
6
6
  pointer: PointerAxisState | null;
7
7
  /** 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. */
8
8
  analog?: Readonly<Record<string, number>> | null;
9
+ /** 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. */
10
+ tick?: number;
9
11
  }
10
12
  export interface InputSnapshot {
13
+ /** Trigger controller vibration for a local user's gamepad when supported. */
14
+ rumble(userId: string, options: {
15
+ strong: number;
16
+ weak: number;
17
+ ms: number;
18
+ }): Promise<boolean>;
11
19
  /** 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. */
12
20
  publish(held: readonly string[]): void;
13
21
  /** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
@@ -12,6 +12,7 @@ export function createInputSnapshot() {
12
12
  return heldSet.has(action) ? 1 : 0;
13
13
  };
14
14
  return {
15
+ rumble: async () => false,
15
16
  publish(held) {
16
17
  previousHeldSet = heldSet;
17
18
  heldList = Object.freeze([...held]);
@@ -0,0 +1,36 @@
1
+ import type { EntityStore } from "../scene/entityStore.js";
2
+ /** A render pose sampled between two simulation steps: `[x, y, z, rotationY]`. */
3
+ export type RenderPose = [x: number, y: number, z: number, rotationY: number];
4
+ /** Serializable previous-step poses keyed by entity id. */
5
+ export interface PoseHistoryState {
6
+ previous: Record<string, RenderPose>;
7
+ }
8
+ /** Previous-step pose buffer: capture at each fixed step, sample between steps for rendering. */
9
+ export interface PoseHistory {
10
+ /** Capture every entity's current pose as the "previous" step. Call once at the start of each fixed step. */
11
+ beginStep(entities: EntityStore): void;
12
+ /**
13
+ * Write the pose `alpha` of the way from the previous step to the live pose into `out`. Returns `false` (and
14
+ * leaves `out` untouched) when the entity is unknown; an entity with no history or one that jumped farther
15
+ * than `snapDistance` gets its live pose.
16
+ */
17
+ sample(entities: EntityStore, id: string, alpha: number, out: RenderPose): boolean;
18
+ /** Forget an entity's previous pose so its next frame renders live (respawn, teleport). */
19
+ snap(id: string): void;
20
+ retune(options: {
21
+ snapDistance?: number;
22
+ }): void;
23
+ snapshot(): PoseHistoryState;
24
+ restore(state: PoseHistoryState): void;
25
+ }
26
+ /** Shortest-arc interpolation between two yaw angles. */
27
+ export declare function lerpAngle(from: number, to: number, t: number): number;
28
+ /**
29
+ * Previous-step pose buffer for render interpolation under a fixed-step loop. Reuses one 4-slot array per
30
+ * entity so a step over N entities allocates only for entities seen for the first time.
31
+ *
32
+ * @capability pose-interpolation render entity poses between fixed simulation steps without stutter
33
+ */
34
+ export declare function createPoseHistory(options?: {
35
+ snapDistance?: number;
36
+ }): PoseHistory;
@@ -0,0 +1,91 @@
1
+ const TWO_PI = Math.PI * 2;
2
+ /** Shortest-arc interpolation between two yaw angles. */
3
+ export function lerpAngle(from, to, t) {
4
+ let delta = (to - from) % TWO_PI;
5
+ if (delta > Math.PI)
6
+ delta -= TWO_PI;
7
+ else if (delta < -Math.PI)
8
+ delta += TWO_PI;
9
+ return from + delta * t;
10
+ }
11
+ /**
12
+ * Previous-step pose buffer for render interpolation under a fixed-step loop. Reuses one 4-slot array per
13
+ * entity so a step over N entities allocates only for entities seen for the first time.
14
+ *
15
+ * @capability pose-interpolation render entity poses between fixed simulation steps without stutter
16
+ */
17
+ export function createPoseHistory(options) {
18
+ let snapDistance = options?.snapDistance ?? 8;
19
+ const previous = new Map();
20
+ const seen = new Set();
21
+ return {
22
+ beginStep(entities) {
23
+ seen.clear();
24
+ for (const entity of entities.list()) {
25
+ seen.add(entity.id);
26
+ let slot = previous.get(entity.id);
27
+ if (slot === undefined) {
28
+ slot = [0, 0, 0, 0];
29
+ previous.set(entity.id, slot);
30
+ }
31
+ slot[0] = entity.position[0];
32
+ slot[1] = entity.position[1];
33
+ slot[2] = entity.position[2];
34
+ slot[3] = entity.rotationY;
35
+ }
36
+ if (previous.size !== seen.size) {
37
+ for (const id of previous.keys()) {
38
+ if (!seen.has(id))
39
+ previous.delete(id);
40
+ }
41
+ }
42
+ },
43
+ sample(entities, id, alpha, out) {
44
+ const live = entities.get(id);
45
+ if (live === null)
46
+ return false;
47
+ const prev = previous.get(id);
48
+ const t = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha;
49
+ if (prev === undefined || t >= 1) {
50
+ out[0] = live.position[0];
51
+ out[1] = live.position[1];
52
+ out[2] = live.position[2];
53
+ out[3] = live.rotationY;
54
+ return true;
55
+ }
56
+ const dx = live.position[0] - prev[0];
57
+ const dy = live.position[1] - prev[1];
58
+ const dz = live.position[2] - prev[2];
59
+ if (dx * dx + dy * dy + dz * dz > snapDistance * snapDistance) {
60
+ out[0] = live.position[0];
61
+ out[1] = live.position[1];
62
+ out[2] = live.position[2];
63
+ out[3] = live.rotationY;
64
+ return true;
65
+ }
66
+ out[0] = prev[0] + dx * t;
67
+ out[1] = prev[1] + dy * t;
68
+ out[2] = prev[2] + dz * t;
69
+ out[3] = lerpAngle(prev[3], live.rotationY, t);
70
+ return true;
71
+ },
72
+ snap(id) {
73
+ previous.delete(id);
74
+ },
75
+ retune(next) {
76
+ if (next.snapDistance !== undefined)
77
+ snapDistance = next.snapDistance;
78
+ },
79
+ snapshot() {
80
+ const copy = {};
81
+ for (const [id, pose] of previous)
82
+ copy[id] = [pose[0], pose[1], pose[2], pose[3]];
83
+ return { previous: copy };
84
+ },
85
+ restore(state) {
86
+ previous.clear();
87
+ for (const [id, pose] of Object.entries(state.previous))
88
+ previous.set(id, [pose[0], pose[1], pose[2], pose[3]]);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,45 @@
1
+ import type { InputFrame } from "./inputSnapshot.js";
2
+ /** Deterministic state transition used to advance one predicted input frame. */
3
+ export type PredictionStep<S> = (state: S, input: InputFrame, dt: number) => S;
4
+ /** One input, tick, and resulting state retained for authoritative replay. */
5
+ export type PredictionRecord<S> = {
6
+ tick: number;
7
+ input: InputFrame;
8
+ state: S;
9
+ dt: number;
10
+ };
11
+ /** Serializable prediction state containing the confirmation baseline and pending input history. */
12
+ export type PredictionSnapshot<S> = {
13
+ confirmedTick: number;
14
+ confirmed: S;
15
+ predicted: S;
16
+ records: PredictionRecord<S>[];
17
+ };
18
+ /** Stateful prediction handle supporting recording, reconciliation, inspection, and restoration. */
19
+ export type PredictionBuffer<S> = {
20
+ record(input: InputFrame, dt?: number, observedState?: S): S;
21
+ reconcile(tick: number, authoritative: S): {
22
+ error: number;
23
+ replayed: number;
24
+ snapped: boolean;
25
+ state: S;
26
+ };
27
+ state(): S;
28
+ snapshot(): PredictionSnapshot<S>;
29
+ restore(next: PredictionSnapshot<S>): void;
30
+ };
31
+ /**
32
+ * Keeps local input predictions ahead of the last server confirmation. Reconciliation replaces the
33
+ * confirmed state and deterministically replays unconfirmed inputs through the supplied step function.
34
+ * `maxTicks` bounds retained work and memory; dropped inputs are folded into the confirmed baseline.
35
+ * @capability client-side input prediction with replay reconciliation
36
+ */
37
+ export declare function createPredictionBuffer<S>(config: {
38
+ initial: S;
39
+ step: PredictionStep<S>;
40
+ maxTicks: number;
41
+ startTick?: number;
42
+ dt?: number;
43
+ /** Error above which a caller should snap its rendered pose to the reconciled result. */
44
+ snapThreshold?: number;
45
+ }): PredictionBuffer<S>;