@jgengine/core 0.9.0 → 0.10.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 (114) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/ai/spawnDirector.d.ts +13 -0
  3. package/dist/ai/spawnDirector.js +40 -16
  4. package/dist/ai/spawnPoint.d.ts +5 -0
  5. package/dist/ai/spawnPoint.js +5 -0
  6. package/dist/audio/audioFalloff.d.ts +5 -2
  7. package/dist/audio/music.d.ts +53 -0
  8. package/dist/audio/music.js +34 -0
  9. package/dist/audio/synth.d.ts +60 -0
  10. package/dist/audio/synth.js +8 -0
  11. package/dist/authoring.d.ts +14 -0
  12. package/dist/authoring.js +14 -0
  13. package/dist/cartridge/runtime.js +2 -2
  14. package/dist/combat/dotField.d.ts +43 -0
  15. package/dist/combat/dotField.js +48 -0
  16. package/dist/combat/projectiles.d.ts +2 -2
  17. package/dist/combat/projectiles.js +18 -5
  18. package/dist/combat/regenShield.d.ts +35 -0
  19. package/dist/combat/regenShield.js +45 -0
  20. package/dist/combat/shotOrigin.d.ts +13 -1
  21. package/dist/combat/shotOrigin.js +31 -2
  22. package/dist/editor/commands.d.ts +65 -0
  23. package/dist/editor/commands.js +193 -0
  24. package/dist/editor/document.d.ts +40 -0
  25. package/dist/editor/document.js +153 -0
  26. package/dist/editor/index.d.ts +30 -0
  27. package/dist/editor/index.js +6 -0
  28. package/dist/editor/types.d.ts +69 -0
  29. package/dist/editor/types.js +29 -0
  30. package/dist/game/connectedPlayers.d.ts +28 -0
  31. package/dist/game/connectedPlayers.js +21 -0
  32. package/dist/game/defineGame.d.ts +42 -1
  33. package/dist/game/defineGame.js +1 -0
  34. package/dist/game/events.d.ts +15 -0
  35. package/dist/game/keyValueStore.d.ts +25 -0
  36. package/dist/game/keyValueStore.js +61 -0
  37. package/dist/game/levelSequence.d.ts +24 -2
  38. package/dist/game/levelSequence.js +51 -2
  39. package/dist/game/lootTable.d.ts +4 -0
  40. package/dist/game/lootTable.js +1 -0
  41. package/dist/game/modelAnimation.d.ts +8 -0
  42. package/dist/game/modelAnimation.js +18 -0
  43. package/dist/game/playableGame.d.ts +68 -5
  44. package/dist/game/recordBook.d.ts +3 -5
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +7 -0
  47. package/dist/input/actionBindings.d.ts +2 -0
  48. package/dist/input/actionBindings.js +1 -0
  49. package/dist/input/axisInput.d.ts +7 -0
  50. package/dist/input/axisInput.js +16 -0
  51. package/dist/interaction/contextMenu.d.ts +1 -0
  52. package/dist/interaction/contextMenu.js +1 -0
  53. package/dist/interaction/proximityPrompt.d.ts +3 -1
  54. package/dist/interaction/proximityPrompt.js +3 -3
  55. package/dist/meta/changelog.d.ts +1 -1
  56. package/dist/meta/changelog.js +22 -1
  57. package/dist/movement/movementModel.d.ts +4 -0
  58. package/dist/movement/movementModel.js +13 -0
  59. package/dist/movement/playerMovement.d.ts +41 -0
  60. package/dist/movement/playerMovement.js +181 -0
  61. package/dist/random/rng.d.ts +2 -0
  62. package/dist/random/rng.js +2 -0
  63. package/dist/render/postProcessing.d.ts +56 -0
  64. package/dist/render/postProcessing.js +1 -0
  65. package/dist/runtime/adapter.d.ts +19 -0
  66. package/dist/runtime/adapter.js +11 -5
  67. package/dist/runtime/gameContext.d.ts +53 -10
  68. package/dist/runtime/gameContext.js +192 -54
  69. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  70. package/dist/runtime/hostedGameRunner.js +62 -0
  71. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  72. package/dist/runtime/hostedWorldSession.js +57 -0
  73. package/dist/runtime/inputSnapshot.d.ts +13 -0
  74. package/dist/runtime/inputSnapshot.js +2 -0
  75. package/dist/runtime/motionIntents.d.ts +2 -0
  76. package/dist/runtime/motionIntents.js +11 -0
  77. package/dist/runtime/worldChannel.d.ts +58 -0
  78. package/dist/runtime/worldChannel.js +69 -0
  79. package/dist/runtime/worldMirror.d.ts +24 -0
  80. package/dist/runtime/worldMirror.js +31 -0
  81. package/dist/runtime/worldReplication.d.ts +46 -0
  82. package/dist/runtime/worldReplication.js +185 -0
  83. package/dist/runtime/worldSnapshot.d.ts +17 -0
  84. package/dist/runtime/worldSnapshot.js +15 -0
  85. package/dist/scene/assetCatalog.d.ts +1 -0
  86. package/dist/scene/colliders.d.ts +6 -0
  87. package/dist/scene/colliders.js +25 -1
  88. package/dist/scene/entityStats.d.ts +4 -0
  89. package/dist/scene/entityStats.js +21 -0
  90. package/dist/scene/entityStore.d.ts +29 -0
  91. package/dist/scene/entityStore.js +60 -0
  92. package/dist/store/observableKeyedStore.d.ts +4 -0
  93. package/dist/store/observableKeyedStore.js +9 -0
  94. package/dist/tactics/fallingGrid.d.ts +2 -2
  95. package/dist/tactics/fallingGrid.js +2 -2
  96. package/dist/ui/gameLayout.d.ts +125 -0
  97. package/dist/ui/gameLayout.js +129 -0
  98. package/dist/ui/orientation.d.ts +39 -0
  99. package/dist/ui/orientation.js +49 -0
  100. package/dist/ui/swingTimer.d.ts +42 -0
  101. package/dist/ui/swingTimer.js +38 -0
  102. package/dist/visibility/spatialIndex.js +34 -20
  103. package/dist/world/environmentSummary.d.ts +11 -2
  104. package/dist/world/environmentSummary.js +11 -1
  105. package/dist/world/features.d.ts +134 -5
  106. package/dist/world/features.js +40 -3
  107. package/dist/world/roads.d.ts +47 -0
  108. package/dist/world/roads.js +146 -0
  109. package/dist/world/streets.d.ts +80 -0
  110. package/dist/world/streets.js +144 -0
  111. package/dist/world/terrain.d.ts +32 -5
  112. package/dist/world/terrain.js +124 -12
  113. package/llms.txt +485 -79
  114. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ import type { CommandResult } from "../commands/commandRegistry.js";
2
+ import type { ModelAssetRef } from "../scene/assetCatalog.js";
3
+ import type { GameContext, GameContextContent } from "./gameContext.js";
4
+ import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
5
+ import { type HostedGameRunner, type InputFrame } from "./hostedGameRunner.js";
6
+ import type { WorldDiff } from "./worldReplication.js";
7
+ import type { 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
+ }
22
+ /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host. */
23
+ export declare function memoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore;
24
+ /** A client replication pull: a full baseline (first sync / fell behind) or a diff since the client's cursor. */
25
+ export type HostedWorldSync = {
26
+ kind: "baseline";
27
+ revision: number;
28
+ snapshot: WorldSnapshot;
29
+ } | {
30
+ kind: "diff";
31
+ diff: WorldDiff;
32
+ };
33
+ /** Config for {@link createHostedWorldSession}: the game, its persistence store, and the auto-save cadence. */
34
+ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMultiplayer> {
35
+ definition: GameDefinition<TAssetRef, TMultiplayer>;
36
+ content: GameContextContent;
37
+ host?: LoopPlayer;
38
+ now?: () => number;
39
+ /** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
40
+ store?: HostedWorldStore;
41
+ /** Minimum elapsed `now()` ms between auto-saves on tick. Default `0` — persist on every revision-changing tick. */
42
+ saveIntervalMs?: number;
43
+ }
44
+ /**
45
+ * The stateful host substrate a long-lived backend binds (ws server, browser P2P host): a live
46
+ * {@link HostedGameRunner} loaded from a {@link HostedWorldStore}, auto-persisted on tick, serving each client a
47
+ * baseline then diffs. The stateless Convex path doesn't use this — it reconstructs a runner from the same store
48
+ * per invocation and diffs with `diffSnapshots`. Either way the store seam and the game code are identical.
49
+ */
50
+ export interface HostedWorldSession {
51
+ join(userId: string, isNew: boolean): void;
52
+ leave(userId: string): void;
53
+ input(userId: string, frame: InputFrame): void;
54
+ command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
55
+ tick(dt: number): number;
56
+ /** Replication pull: `null`/`0` cursor → full baseline; a prior revision → a diff since it. */
57
+ sync(sinceRevision: number | null): HostedWorldSync;
58
+ revision(): number;
59
+ members(): readonly string[];
60
+ /** Force-persist the current world to the store. */
61
+ save(): void;
62
+ runner(): HostedGameRunner;
63
+ }
64
+ /** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick. */
65
+ export declare function createHostedWorldSession<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedWorldSessionOptions<TAssetRef, TMultiplayer>): HostedWorldSession;
@@ -0,0 +1,57 @@
1
+ import { createHostedGameRunner } from "./hostedGameRunner.js";
2
+ /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host. */
3
+ export function memoryWorldStore(seed) {
4
+ let record = seed ?? null;
5
+ return {
6
+ load: () => record,
7
+ save(next) {
8
+ record = { snapshot: next.snapshot, revision: next.revision };
9
+ },
10
+ };
11
+ }
12
+ /** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick. */
13
+ export function createHostedWorldSession(options) {
14
+ const { definition, content, host, now, saveIntervalMs = 0 } = options;
15
+ const store = options.store ?? memoryWorldStore();
16
+ const loaded = store.load();
17
+ const runner = createHostedGameRunner({
18
+ definition,
19
+ content,
20
+ ...(host === undefined ? {} : { host }),
21
+ ...(now === undefined ? {} : { now }),
22
+ ...(loaded === null ? {} : { restore: loaded.snapshot }),
23
+ });
24
+ let savedRevision = loaded?.revision ?? 0;
25
+ let lastSaveAt = now?.() ?? 0;
26
+ function persist() {
27
+ savedRevision = runner.revision();
28
+ store.save({ snapshot: runner.snapshot(), revision: savedRevision });
29
+ }
30
+ return {
31
+ join: (userId, isNew) => runner.join(userId, isNew),
32
+ leave: (userId) => runner.leave(userId),
33
+ input: (userId, frame) => runner.input(userId, frame),
34
+ command: (userId, name, input) => runner.command(userId, name, input),
35
+ tick(dt) {
36
+ const revision = runner.tick(dt);
37
+ if (revision !== savedRevision) {
38
+ const at = now?.() ?? 0;
39
+ if (saveIntervalMs <= 0 || at - lastSaveAt >= saveIntervalMs) {
40
+ lastSaveAt = at;
41
+ persist();
42
+ }
43
+ }
44
+ return revision;
45
+ },
46
+ sync(sinceRevision) {
47
+ if (sinceRevision === null || sinceRevision <= 0) {
48
+ return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
49
+ }
50
+ return { kind: "diff", diff: runner.diff(sinceRevision) };
51
+ },
52
+ revision: () => runner.revision(),
53
+ members: () => runner.members(),
54
+ save: persist,
55
+ runner: () => runner,
56
+ };
57
+ }
@@ -1,4 +1,10 @@
1
1
  import type { PointerAxisState } from "../input/pointerAxis.js";
2
+ import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
3
+ /** 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
+ export interface InputFrame {
5
+ held: readonly string[];
6
+ pointer: PointerAxisState | null;
7
+ }
2
8
  export interface InputSnapshot {
3
9
  /** Replaces the held-action set for this frame. Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
4
10
  publish(held: readonly string[]): void;
@@ -8,5 +14,12 @@ export interface InputSnapshot {
8
14
  held(): readonly string[];
9
15
  /** Pointer position over the play surface, `[-1, 1]` per axis with `+y` down, published by the shell each frame; `null` until the first pointer move. */
10
16
  pointer(): PointerAxisState | null;
17
+ /**
18
+ * Instantaneous analog axis sample against the held-action set and current pointer (#533.7) — bind
19
+ * throttle/steer/handbrake (or any axes) to *action names*, not raw key codes, since the held set is
20
+ * semantic actions. Feed the result into an `AxisChannel` for smoothing; unlisted ranges default to
21
+ * bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals.
22
+ */
23
+ axis<TAxes extends string>(bindings: Record<TAxes, AxisBinding>, ranges?: Partial<Record<TAxes, AxisRange>>): Record<TAxes, number>;
11
24
  }
12
25
  export declare function createInputSnapshot(): InputSnapshot;
@@ -1,3 +1,4 @@
1
+ import { sampleAxisBindings } from "../input/axisInput.js";
1
2
  export function createInputSnapshot() {
2
3
  let heldSet = new Set();
3
4
  let heldList = [];
@@ -13,5 +14,6 @@ export function createInputSnapshot() {
13
14
  isDown: (action) => heldSet.has(action),
14
15
  held: () => heldList,
15
16
  pointer: () => pointerState,
17
+ axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges),
16
18
  };
17
19
  }
@@ -29,5 +29,7 @@ export interface MotionIntentBatch {
29
29
  y: number | null;
30
30
  }
31
31
  export declare function createMotionIntents(): MotionIntents;
32
+ /** Fold a batch's vertical impulses into a controller's velocity, then apply an outright `setVerticalVelocity` override — the vertical counterpart of {@link applyHorizontalImpulses}. */
33
+ export declare function applyMotionImpulses(currentVelocity: number, batch: MotionIntentBatch | null): number;
32
34
  /** Fold a batch's horizontal pushes into a controller's velocity pair — shared by the walk and voxel drivers. */
33
35
  export declare function applyHorizontalImpulses(velocityX: number, velocityZ: number, batch: MotionIntentBatch | null): readonly [number, number];
@@ -29,6 +29,17 @@ export function createMotionIntents() {
29
29
  },
30
30
  };
31
31
  }
32
+ /** Fold a batch's vertical impulses into a controller's velocity, then apply an outright `setVerticalVelocity` override — the vertical counterpart of {@link applyHorizontalImpulses}. */
33
+ export function applyMotionImpulses(currentVelocity, batch) {
34
+ if (batch === null)
35
+ return currentVelocity;
36
+ let velocity = currentVelocity;
37
+ for (const impulse of batch.impulses)
38
+ velocity += impulse;
39
+ if (batch.verticalVelocity !== null)
40
+ velocity = batch.verticalVelocity;
41
+ return velocity;
42
+ }
32
43
  /** Fold a batch's horizontal pushes into a controller's velocity pair — shared by the walk and voxel drivers. */
33
44
  export function applyHorizontalImpulses(velocityX, velocityZ, batch) {
34
45
  if (batch === null || batch.horizontalImpulses.length === 0)
@@ -0,0 +1,58 @@
1
+ import type { GameContext } from "./gameContext.js";
2
+ import type { InputFrame } from "./hostedGameRunner.js";
3
+ import type { HostedWorldSession } from "./hostedWorldSession.js";
4
+ import type { WorldDiff } from "./worldReplication.js";
5
+ import type { WorldSnapshot } from "./worldSnapshot.js";
6
+ /** Host→client frame: the full baseline a joiner needs, then per-tick diffs. What a transport marshals downstream. */
7
+ export type WorldServerFrame = {
8
+ t: "baseline";
9
+ revision: number;
10
+ snapshot: WorldSnapshot;
11
+ } | {
12
+ t: "diff";
13
+ diff: WorldDiff;
14
+ };
15
+ /** Client→host frame: a player's session verbs. What a transport marshals upstream. */
16
+ export type WorldClientFrame = {
17
+ t: "join";
18
+ isNew: boolean;
19
+ } | {
20
+ t: "leave";
21
+ } | {
22
+ t: "command";
23
+ name: string;
24
+ input: unknown;
25
+ } | {
26
+ t: "input";
27
+ frame: InputFrame;
28
+ };
29
+ /** One client's link on the host side: routes its upstream frames into the shared session, pushes it sync frames. */
30
+ export interface WorldHostConnection {
31
+ receive(frame: WorldClientFrame): void;
32
+ push(): void;
33
+ close(): void;
34
+ }
35
+ /**
36
+ * The transport-agnostic host: fans one {@link HostedWorldSession} out to many connections, each tracking its own
37
+ * revision cursor so a joiner gets a baseline and everyone else gets diffs. A ws server, a Convex function, or an
38
+ * in-process loopback all drive the same shape — decode a frame → `connection.receive`; after `session.tick` →
39
+ * `broadcast`. No wire format is assumed; frames are plain data a transport serializes however it likes.
40
+ */
41
+ export interface WorldHost {
42
+ connect(userId: string, send: (frame: WorldServerFrame) => void): WorldHostConnection;
43
+ broadcast(): void;
44
+ session(): HostedWorldSession;
45
+ }
46
+ /** Build a {@link WorldHost} fanning one {@link HostedWorldSession} out to many cursor-tracked connections. */
47
+ export declare function createWorldHost(session: HostedWorldSession): WorldHost;
48
+ /** The client end of a {@link WorldHost} connection: applies server frames to a local `ctx`, sends session verbs upstream. */
49
+ export interface WorldClientLink {
50
+ receive(frame: WorldServerFrame): void;
51
+ join(isNew: boolean): void;
52
+ leave(): void;
53
+ command(name: string, input: unknown): void;
54
+ input(frame: InputFrame): void;
55
+ revision(): number;
56
+ }
57
+ /** Build a {@link WorldClientLink} — the client end that mirrors host frames into `ctx` and sends session verbs upstream. */
58
+ export declare function createWorldClientLink(ctx: Pick<GameContext, "hydrate">, send: (frame: WorldClientFrame) => void): WorldClientLink;
@@ -0,0 +1,69 @@
1
+ import { createWorldMirror } from "./worldMirror.js";
2
+ /** Build a {@link WorldHost} fanning one {@link HostedWorldSession} out to many cursor-tracked connections. */
3
+ export function createWorldHost(session) {
4
+ const connections = new Set();
5
+ function connect(userId, send) {
6
+ let cursor = 0;
7
+ const connection = {
8
+ receive(frame) {
9
+ switch (frame.t) {
10
+ case "join":
11
+ session.join(userId, frame.isNew);
12
+ break;
13
+ case "leave":
14
+ session.leave(userId);
15
+ break;
16
+ case "command":
17
+ session.command(userId, frame.name, frame.input);
18
+ break;
19
+ case "input":
20
+ session.input(userId, frame.frame);
21
+ break;
22
+ }
23
+ },
24
+ push() {
25
+ if (cursor !== 0 && cursor === session.revision())
26
+ return;
27
+ const sync = session.sync(cursor === 0 ? null : cursor);
28
+ if (sync.kind === "baseline") {
29
+ cursor = sync.revision;
30
+ send({ t: "baseline", revision: sync.revision, snapshot: sync.snapshot });
31
+ }
32
+ else {
33
+ cursor = sync.diff.revision;
34
+ send({ t: "diff", diff: sync.diff });
35
+ }
36
+ },
37
+ close() {
38
+ connections.delete(connection);
39
+ },
40
+ };
41
+ connections.add(connection);
42
+ return connection;
43
+ }
44
+ return {
45
+ connect,
46
+ broadcast() {
47
+ for (const connection of connections)
48
+ connection.push();
49
+ },
50
+ session: () => session,
51
+ };
52
+ }
53
+ /** Build a {@link WorldClientLink} — the client end that mirrors host frames into `ctx` and sends session verbs upstream. */
54
+ export function createWorldClientLink(ctx, send) {
55
+ const mirror = createWorldMirror(ctx);
56
+ return {
57
+ receive(frame) {
58
+ if (frame.t === "baseline")
59
+ mirror.applyBaseline(frame.revision, frame.snapshot);
60
+ else
61
+ mirror.applyDiff(frame.diff);
62
+ },
63
+ join: (isNew) => send({ t: "join", isNew }),
64
+ leave: () => send({ t: "leave" }),
65
+ command: (name, input) => send({ t: "command", name, input }),
66
+ input: (frame) => send({ t: "input", frame }),
67
+ revision: () => mirror.revision(),
68
+ };
69
+ }
@@ -0,0 +1,24 @@
1
+ import type { GameContext } from "./gameContext.js";
2
+ import type { HostedWorldSession } from "./hostedWorldSession.js";
3
+ import { type WorldDiff } from "./worldReplication.js";
4
+ import type { WorldSnapshot } from "./worldSnapshot.js";
5
+ /**
6
+ * The client end of host-authoritative replication: folds a host's baseline + {@link WorldDiff} stream onto a
7
+ * local {@link GameContext}. It keeps the last full {@link WorldSnapshot}, advances it with each diff, and pushes
8
+ * the result through `ctx.hydrate` — so the client mirrors exactly the subsystems its own game opted into and
9
+ * silently ignores host modules it lacks. This is the inverse of a {@link HostedWorldSession}; the transport in
10
+ * between (loopback, ws, Convex) is irrelevant.
11
+ */
12
+ export interface WorldMirror {
13
+ applyBaseline(revision: number, snapshot: WorldSnapshot): void;
14
+ applyDiff(diff: WorldDiff): void;
15
+ revision(): number;
16
+ }
17
+ /** Build a {@link WorldMirror} that replicates a host's baseline + diff stream onto `ctx` via `ctx.hydrate`. */
18
+ export declare function createWorldMirror(ctx: Pick<GameContext, "hydrate">): WorldMirror;
19
+ /**
20
+ * Pull one replication step from a co-located {@link HostedWorldSession} into a {@link WorldMirror} — the
21
+ * no-network local path (host and client in one process). A fresh mirror pulls a baseline; thereafter it pulls
22
+ * a diff since its own revision. The same `sync(sinceRevision)` call is what a networked transport marshals.
23
+ */
24
+ export declare function pullWorld(session: HostedWorldSession, mirror: WorldMirror): void;
@@ -0,0 +1,31 @@
1
+ import { applyWorldDiff } from "./worldReplication.js";
2
+ /** Build a {@link WorldMirror} that replicates a host's baseline + diff stream onto `ctx` via `ctx.hydrate`. */
3
+ export function createWorldMirror(ctx) {
4
+ let mirror = {};
5
+ let revision = 0;
6
+ return {
7
+ applyBaseline(nextRevision, snapshot) {
8
+ mirror = snapshot;
9
+ revision = nextRevision;
10
+ ctx.hydrate(snapshot);
11
+ },
12
+ applyDiff(diff) {
13
+ mirror = applyWorldDiff(mirror, diff);
14
+ revision = diff.revision;
15
+ ctx.hydrate(mirror);
16
+ },
17
+ revision: () => revision,
18
+ };
19
+ }
20
+ /**
21
+ * Pull one replication step from a co-located {@link HostedWorldSession} into a {@link WorldMirror} — the
22
+ * no-network local path (host and client in one process). A fresh mirror pulls a baseline; thereafter it pulls
23
+ * a diff since its own revision. The same `sync(sinceRevision)` call is what a networked transport marshals.
24
+ */
25
+ export function pullWorld(session, mirror) {
26
+ const sync = session.sync(mirror.revision() === 0 ? null : mirror.revision());
27
+ if (sync.kind === "baseline")
28
+ mirror.applyBaseline(sync.revision, sync.snapshot);
29
+ else
30
+ mirror.applyDiff(sync.diff);
31
+ }
@@ -0,0 +1,46 @@
1
+ import type { SceneEntity } from "../scene/entityStore.js";
2
+ import type { StatValueMap } from "../scene/entityStats.js";
3
+ import type { WorldSnapshot } from "./worldSnapshot.js";
4
+ /**
5
+ * A revision-stamped delta over a {@link WorldSnapshot}. The host sends one per tick to each client, carrying
6
+ * only what changed since that client's last acknowledged revision — entity/stat/store deltas plus whole
7
+ * snapshots of any other opted-in module (feed, leaderboard, chat, …) that changed. Fold it onto a prior
8
+ * baseline with {@link applyWorldDiff}.
9
+ */
10
+ export interface WorldDiff {
11
+ revision: number;
12
+ entities: readonly SceneEntity[];
13
+ removedEntities: readonly string[];
14
+ stats: Record<string, StatValueMap>;
15
+ removedStats: readonly string[];
16
+ store: readonly (readonly [string, unknown])[];
17
+ removedStore: readonly string[];
18
+ /** Changed whole-module snapshots keyed by module name — everything a client can't reconstruct from deltas. */
19
+ modules: WorldSnapshot;
20
+ }
21
+ /**
22
+ * Turns successive full {@link WorldSnapshot}s into per-client {@link WorldDiff}s. Each `commit()` re-reads the
23
+ * world, stamps every item that changed with the new revision, and remembers removals; `diff(sinceRevision)`
24
+ * then replays exactly the items stamped after that revision. Everything the tracker holds is JSON — the same
25
+ * shape that rides the wire — so a diff is inherently serializable. Change-detection is a full re-serialize per
26
+ * commit; dirty-hint acceleration is a later optimization behind the same seam.
27
+ */
28
+ export declare function createWorldReplicator(takeSnapshot: () => WorldSnapshot): {
29
+ commit: () => number;
30
+ diff: (sinceRevision: number) => WorldDiff;
31
+ revision: () => number;
32
+ };
33
+ /** The stateful diff tracker returned by {@link createWorldReplicator}: `commit()`, `diff(sinceRevision)`, `revision()`. */
34
+ export type WorldReplicator = ReturnType<typeof createWorldReplicator>;
35
+ /**
36
+ * Diff two full {@link WorldSnapshot}s directly, stamping the result at `revision` — the stateless counterpart of
37
+ * {@link createWorldReplicator} for hosts that persist snapshots rather than keep a live tracker (Convex reconstructs
38
+ * per invocation). `applyWorldDiff(prev, diffSnapshots(prev, next, r))` reproduces `next`.
39
+ */
40
+ export declare function diffSnapshots(prev: WorldSnapshot, next: WorldSnapshot, revision: number): WorldDiff;
41
+ /**
42
+ * Fold a {@link WorldDiff} onto a prior {@link WorldSnapshot} baseline, returning the next full snapshot — the
43
+ * client-side inverse of {@link createWorldReplicator}. Pure data in, pure data out: upserts changed entities,
44
+ * stats and store keys, drops the removed ones, and replaces changed module snapshots wholesale.
45
+ */
46
+ export declare function applyWorldDiff(baseline: WorldSnapshot, diff: WorldDiff): WorldSnapshot;
@@ -0,0 +1,185 @@
1
+ const CORE_KEYS = new Set(["entities", "stats", "store"]);
2
+ /**
3
+ * Turns successive full {@link WorldSnapshot}s into per-client {@link WorldDiff}s. Each `commit()` re-reads the
4
+ * world, stamps every item that changed with the new revision, and remembers removals; `diff(sinceRevision)`
5
+ * then replays exactly the items stamped after that revision. Everything the tracker holds is JSON — the same
6
+ * shape that rides the wire — so a diff is inherently serializable. Change-detection is a full re-serialize per
7
+ * commit; dirty-hint acceleration is a later optimization behind the same seam.
8
+ */
9
+ export function createWorldReplicator(takeSnapshot) {
10
+ let revision = 0;
11
+ const entities = new Map();
12
+ const removedEntities = new Map();
13
+ const stats = new Map();
14
+ const removedStats = new Map();
15
+ const store = new Map();
16
+ const removedStore = new Map();
17
+ const modules = new Map();
18
+ function diffMap(live, tracked, removed, nextRevision) {
19
+ let changed = false;
20
+ for (const [key, json] of live) {
21
+ const prev = tracked.get(key);
22
+ if (prev === undefined || prev.json !== json) {
23
+ tracked.set(key, { json, revision: nextRevision });
24
+ removed.delete(key);
25
+ changed = true;
26
+ }
27
+ }
28
+ for (const key of tracked.keys()) {
29
+ if (!live.has(key)) {
30
+ tracked.delete(key);
31
+ removed.set(key, nextRevision);
32
+ changed = true;
33
+ }
34
+ }
35
+ return changed;
36
+ }
37
+ function commit() {
38
+ const snapshot = takeSnapshot();
39
+ const nextRevision = revision + 1;
40
+ const liveEntities = new Map();
41
+ for (const entity of (snapshot["entities"] ?? [])) {
42
+ liveEntities.set(entity.id, JSON.stringify(entity));
43
+ }
44
+ const liveStats = new Map();
45
+ for (const [id, value] of Object.entries((snapshot["stats"] ?? {}))) {
46
+ liveStats.set(id, JSON.stringify(value));
47
+ }
48
+ const liveStore = new Map();
49
+ for (const [key, value] of (snapshot["store"] ?? [])) {
50
+ liveStore.set(key, JSON.stringify(value ?? null));
51
+ }
52
+ const liveModules = new Map();
53
+ for (const [key, value] of Object.entries(snapshot)) {
54
+ if (CORE_KEYS.has(key))
55
+ continue;
56
+ liveModules.set(key, JSON.stringify(value ?? null));
57
+ }
58
+ let changed = diffMap(liveEntities, entities, removedEntities, nextRevision);
59
+ changed = diffMap(liveStats, stats, removedStats, nextRevision) || changed;
60
+ changed = diffMap(liveStore, store, removedStore, nextRevision) || changed;
61
+ changed = diffMap(liveModules, modules, new Map(), nextRevision) || changed;
62
+ if (changed)
63
+ revision = nextRevision;
64
+ return revision;
65
+ }
66
+ function diff(sinceRevision) {
67
+ const changedEntities = [];
68
+ for (const [, tracked] of entities) {
69
+ if (tracked.revision > sinceRevision)
70
+ changedEntities.push(JSON.parse(tracked.json));
71
+ }
72
+ const changedStats = {};
73
+ for (const [id, tracked] of stats) {
74
+ if (tracked.revision > sinceRevision)
75
+ changedStats[id] = JSON.parse(tracked.json);
76
+ }
77
+ const changedStore = [];
78
+ for (const [key, tracked] of store) {
79
+ if (tracked.revision > sinceRevision)
80
+ changedStore.push([key, JSON.parse(tracked.json)]);
81
+ }
82
+ const changedModules = {};
83
+ for (const [key, tracked] of modules) {
84
+ if (tracked.revision > sinceRevision)
85
+ changedModules[key] = JSON.parse(tracked.json);
86
+ }
87
+ return {
88
+ revision,
89
+ entities: changedEntities,
90
+ removedEntities: keysAfter(removedEntities, sinceRevision),
91
+ stats: changedStats,
92
+ removedStats: keysAfter(removedStats, sinceRevision),
93
+ store: changedStore,
94
+ removedStore: keysAfter(removedStore, sinceRevision),
95
+ modules: changedModules,
96
+ };
97
+ }
98
+ return { commit, diff, revision: () => revision };
99
+ }
100
+ function keysAfter(map, sinceRevision) {
101
+ const out = [];
102
+ for (const [key, revision] of map) {
103
+ if (revision > sinceRevision)
104
+ out.push(key);
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Diff two full {@link WorldSnapshot}s directly, stamping the result at `revision` — the stateless counterpart of
110
+ * {@link createWorldReplicator} for hosts that persist snapshots rather than keep a live tracker (Convex reconstructs
111
+ * per invocation). `applyWorldDiff(prev, diffSnapshots(prev, next, r))` reproduces `next`.
112
+ */
113
+ export function diffSnapshots(prev, next, revision) {
114
+ const prevEntities = new Map();
115
+ for (const entity of (prev["entities"] ?? [])) {
116
+ prevEntities.set(entity.id, JSON.stringify(entity));
117
+ }
118
+ const entities = [];
119
+ const nextEntityIds = new Set();
120
+ for (const entity of (next["entities"] ?? [])) {
121
+ nextEntityIds.add(entity.id);
122
+ const json = JSON.stringify(entity);
123
+ if (prevEntities.get(entity.id) !== json)
124
+ entities.push(JSON.parse(json));
125
+ }
126
+ const removedEntities = [...prevEntities.keys()].filter((id) => !nextEntityIds.has(id));
127
+ const prevStats = (prev["stats"] ?? {});
128
+ const nextStats = (next["stats"] ?? {});
129
+ const stats = {};
130
+ for (const [id, value] of Object.entries(nextStats)) {
131
+ const json = JSON.stringify(value);
132
+ if (JSON.stringify(prevStats[id]) !== json)
133
+ stats[id] = JSON.parse(json);
134
+ }
135
+ const removedStats = Object.keys(prevStats).filter((id) => !(id in nextStats));
136
+ const prevStore = new Map((prev["store"] ?? []));
137
+ const nextStore = new Map((next["store"] ?? []));
138
+ const store = [];
139
+ for (const [key, value] of nextStore) {
140
+ const json = JSON.stringify(value ?? null);
141
+ if (JSON.stringify(prevStore.get(key) ?? null) !== json)
142
+ store.push([key, JSON.parse(json)]);
143
+ }
144
+ const removedStore = [...prevStore.keys()].filter((key) => !nextStore.has(key));
145
+ const modules = {};
146
+ for (const [key, value] of Object.entries(next)) {
147
+ if (CORE_KEYS.has(key))
148
+ continue;
149
+ const json = JSON.stringify(value ?? null);
150
+ if (JSON.stringify(prev[key] ?? null) !== json)
151
+ modules[key] = JSON.parse(json);
152
+ }
153
+ return { revision, entities, removedEntities, stats, removedStats, store, removedStore, modules };
154
+ }
155
+ /**
156
+ * Fold a {@link WorldDiff} onto a prior {@link WorldSnapshot} baseline, returning the next full snapshot — the
157
+ * client-side inverse of {@link createWorldReplicator}. Pure data in, pure data out: upserts changed entities,
158
+ * stats and store keys, drops the removed ones, and replaces changed module snapshots wholesale.
159
+ */
160
+ export function applyWorldDiff(baseline, diff) {
161
+ const next = { ...baseline };
162
+ const entityById = new Map();
163
+ for (const entity of (baseline["entities"] ?? []))
164
+ entityById.set(entity.id, entity);
165
+ for (const entity of diff.entities)
166
+ entityById.set(entity.id, entity);
167
+ for (const id of diff.removedEntities)
168
+ entityById.delete(id);
169
+ next["entities"] = Array.from(entityById.values());
170
+ const statsById = { ...(baseline["stats"] ?? {}) };
171
+ for (const [id, value] of Object.entries(diff.stats))
172
+ statsById[id] = value;
173
+ for (const id of diff.removedStats)
174
+ delete statsById[id];
175
+ next["stats"] = statsById;
176
+ const storeByKey = new Map((baseline["store"] ?? []));
177
+ for (const [key, value] of diff.store)
178
+ storeByKey.set(key, value);
179
+ for (const key of diff.removedStore)
180
+ storeByKey.delete(key);
181
+ next["store"] = Array.from(storeByKey.entries());
182
+ for (const [key, value] of Object.entries(diff.modules))
183
+ next[key] = value;
184
+ return next;
185
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The replication seam for host-authoritative shared worlds: the opt-in feature manifest *is* the
3
+ * replication schema. Each live subsystem a game opts into registers a {@link SnapshotModule} keyed
4
+ * by name; the host serializes exactly the registered set into a {@link WorldSnapshot} and a client
5
+ * hydrates the same keys back. Adding a replicated subsystem is a registration, never a new branch.
6
+ */
7
+ export interface SnapshotModule<T = unknown> {
8
+ readonly key: string;
9
+ snapshot(): T;
10
+ hydrate(data: T): void;
11
+ }
12
+ /** Full world baseline keyed by {@link SnapshotModule.key} — one entry per opted-in subsystem. */
13
+ export type WorldSnapshot = Record<string, unknown>;
14
+ /** Serialize every registered module into one keyed baseline — the host→client full-world send. */
15
+ export declare function composeWorldSnapshot(modules: readonly SnapshotModule[]): WorldSnapshot;
16
+ /** Hydrate every registered module whose key is present in `snapshot`; keys absent from it are left untouched. */
17
+ export declare function applyWorldSnapshot(modules: readonly SnapshotModule[], snapshot: WorldSnapshot): void;
@@ -0,0 +1,15 @@
1
+ /** Serialize every registered module into one keyed baseline — the host→client full-world send. */
2
+ export function composeWorldSnapshot(modules) {
3
+ const snapshot = {};
4
+ for (const module of modules)
5
+ snapshot[module.key] = module.snapshot();
6
+ return snapshot;
7
+ }
8
+ /** Hydrate every registered module whose key is present in `snapshot`; keys absent from it are left untouched. */
9
+ export function applyWorldSnapshot(modules, snapshot) {
10
+ for (const module of modules) {
11
+ if (Object.prototype.hasOwnProperty.call(snapshot, module.key)) {
12
+ module.hydrate(snapshot[module.key]);
13
+ }
14
+ }
15
+ }
@@ -1,3 +1,4 @@
1
+ /** Measured horizontal footprint, footprint center, and lowest Y of a model in model space. */
1
2
  export interface ModelDims {
2
3
  footprint: {
3
4
  w: number;
@@ -33,8 +33,14 @@ export interface ResolvedCollider {
33
33
  }
34
34
  export declare const DEFAULT_ENTITY_HIT_RADIUS = 0.5;
35
35
  export declare const DEFAULT_OBJECT_HALF_EXTENTS: EntityPosition;
36
+ /** Matches the shell's fallback actor capsule (~0.7m wide, feet at y=0 to head at ~1.8m). */
37
+ export declare const DEFAULT_ENTITY_BODY_HALF_EXTENTS: EntityPosition;
38
+ /** Entity-local center of the default body hitbox (half its height above the feet). */
39
+ export declare const DEFAULT_ENTITY_BODY_OFFSET: EntityPosition;
36
40
  export declare function defaultEntityColliders(): EntityColliderSet;
37
41
  export declare function defaultObjectColliders(halfExtents?: EntityPosition): EntityColliderSet;
42
+ /** Blocking physical body derived from an object's rendered scale: a grounded box spanning the visual (base at y=0, matching the shell's fallback mesh). */
43
+ export declare function scaledObjectColliders(scale: readonly [number, number, number]): EntityColliderSet;
38
44
  export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
39
45
  export declare function worldOffset(local: EntityPosition | undefined, position: EntityPosition, rotationY: number): EntityPosition;
40
46
  export declare function colliderWorldCenter(collider: ResolvedCollider, position: EntityPosition, rotationY: number): EntityPosition;