@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
@@ -28,18 +28,20 @@ import { type PoseAllowedStates, type PoseState } from "../movement/poseState.js
28
28
  import type { ModelAssetRef } from "../scene/assetCatalog.js";
29
29
  import { type PaintLayer } from "../scene/paintLayer.js";
30
30
  import { type EntityStatsApi, type StatCatalog } from "../scene/entityStats.js";
31
- import type { EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
31
+ import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
32
32
  import { type Forms } from "../scene/form.js";
33
- import type { EntityColliderSet } from "../scene/colliders.js";
33
+ import { type EntityColliderSet } from "../scene/colliders.js";
34
34
  import { type ObjectRaycastHit, type ObjectRaycastInput } from "../scene/objectQuery.js";
35
35
  import { type ObjectStore } from "../scene/objectStore.js";
36
36
  import { type Roster } from "../scene/roster.js";
37
+ import { type ConnectedPlayers } from "../game/connectedPlayers.js";
37
38
  import { type Possession } from "../scene/possession.js";
38
39
  import { type SceneRaycastHit, type SceneRaycastInput } from "../scene/sceneRaycast.js";
39
40
  import { type SpatialApi } from "../scene/spatial.js";
40
41
  import { type CycleTargetOptions } from "../scene/targeting.js";
41
42
  import { type Stats } from "../stats/statModifiers.js";
42
43
  import { type ObservableKeyedStore } from "../store/observableKeyedStore.js";
44
+ import { type WorldSnapshot } from "./worldSnapshot.js";
43
45
  import { type SimClock } from "../time/simClock.js";
44
46
  import { type TurnLoop, type TurnLoopConfig } from "../turn/turnLoop.js";
45
47
  import { RaceState, type RaceStateConfig } from "../game/race.js";
@@ -145,6 +147,7 @@ export interface SceneEntityContext {
145
147
  spawnPoseOf(instanceId: string): SpawnPose | null;
146
148
  resetToSpawn(instanceId: string): boolean;
147
149
  resetAllToSpawn(filter?: (entity: SceneEntity) => boolean): number;
150
+ blackboard: EntityBlackboard;
148
151
  stats: EntityStatsApi;
149
152
  floatText(input: FloatTextInput): void;
150
153
  telegraph(input: TelegraphInput): () => void;
@@ -189,6 +192,10 @@ export interface GameContextCommands {
189
192
  has(name: string): boolean;
190
193
  names(): string[];
191
194
  run(name: string, input: unknown): CommandResult<GameContext>;
195
+ /** Run a command attributed to `actorUserId` — a shared-world host routes each client's command through this so the handler can read {@link actor}. */
196
+ runAs(actorUserId: string, name: string, input: unknown): CommandResult<GameContext>;
197
+ /** The user a command is running on behalf of, or `null` outside a {@link runAs} call — commands default to `ctx.player.userId` when this is `null`. */
198
+ actor(): string | null;
192
199
  }
193
200
  export interface GameContextFeed extends Omit<GameFeed, "bind"> {
194
201
  bind(action: keyof GameEventMap): () => void;
@@ -228,6 +235,13 @@ export interface GameContextRace {
228
235
  /** Lazily creates (on first call, `config` required) or returns the existing reactive race for `id` — discrete mutations and eventful `update` calls bump `ctx.version()`, so HUDs stop hand-managing `store.set` (#286.2). */
229
236
  state(id: string, config?: RaceStateConfig): RaceState;
230
237
  }
238
+ /** Reachable audio seam on `ctx.game`: `play`, `music`, and `resume` route through the `audio.play`/`audio.music`/`audio.resume` events the shell's audio engine listens on, so game code triggers sound without importing the shell. */
239
+ export interface GameAudio {
240
+ play(sound: string, at?: readonly [number, number, number]): void;
241
+ /** Crossfade the procedural soundtrack to `theme` (null fades out); `transpose` shifts the incoming theme's key in semitones. */
242
+ music(theme: string | null, transpose?: number): void;
243
+ resume(): void;
244
+ }
231
245
  export interface GameContext {
232
246
  scene: {
233
247
  object: SceneObjectContext;
@@ -240,27 +254,46 @@ export interface GameContext {
240
254
  game: {
241
255
  commands: GameContextCommands;
242
256
  events: GameEvents;
257
+ audio: GameAudio;
258
+ /** Play a model's one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an attack swing) — emits `entity.animation` for the shell to pick up. */
259
+ playEntityAnimation(instanceId: string, event: string): void;
243
260
  feed: GameContextFeed;
244
261
  loot: GameContextLoot;
245
262
  trade: TradeSystem;
246
263
  quest: QuestJournal;
247
- social: Social;
248
- chat: Chat;
264
+ /** Friends/party/presence/emotes/world-invites — present only when `features.social` is set. */
265
+ social?: Social;
266
+ /** Channels + messages — present only when `features.chat` is set (implies `social`). */
267
+ chat?: Chat;
249
268
  unlocks: Unlocks;
250
269
  economy: GameContextEconomy;
251
- leaderboard: Leaderboard;
252
- roster: Roster;
270
+ /** Competitive score tracking — present only when `features.leaderboard` is set. */
271
+ leaderboard?: Leaderboard;
272
+ /** Owned-entity roster — present only when `features.roster` is set. */
273
+ roster?: Roster;
253
274
  /** Game-defined keyed reactive store slot (#163.1); mutations bump `ctx.version()`/notify `ctx.subscribe`. */
254
275
  store: ObservableKeyedStore<unknown>;
255
- cards: GameContextCards;
256
- turn: GameContextTurn;
257
- race: GameContextRace;
276
+ /** Card pile zones — present only when `features.cards` is set. */
277
+ cards?: GameContextCards;
278
+ /** Turn/phase loop — present only when `features.turn` is set. */
279
+ turn?: GameContextTurn;
280
+ /** Lap/checkpoint race state — present only when `features.race` is set. */
281
+ race?: GameContextRace;
282
+ /** Connected-player set for a shared world — present only when `features.players` is set. */
283
+ players?: ConnectedPlayers;
258
284
  };
259
285
  player: {
286
+ /** The acting player's id — the command actor inside `runAs`, the local player everywhere else. */
260
287
  userId: string;
261
288
  isNew: boolean;
289
+ /** The acting player's inventory set — the command actor's bags inside `runAs`, the local player's everywhere else. */
262
290
  inventory: InventorySet<string>;
291
+ /** A specific player's inventory set — how a host reads or grants into any connected player's bags. */
292
+ inventoryFor(userId: string): InventorySet<string>;
293
+ /** The acting player's stat modifiers — the command actor's inside `runAs`, the local player's everywhere else. */
263
294
  stats: Stats<string>;
295
+ /** A specific player's stat modifiers — how a host reads or buffs any connected player's stats. */
296
+ statsFor(userId: string): Stats<string>;
264
297
  loadout: Loadouts;
265
298
  applyLoadout(userId: string, loadoutId: string): {
266
299
  reason: string;
@@ -268,8 +301,10 @@ export interface GameContext {
268
301
  movement: PoseState;
269
302
  possession: Possession;
270
303
  cosmetics: Cosmetics;
271
- /** Vertical-motion seam into the shell-owned FrameDriver (#162.4); see `MotionIntents`. */
304
+ /** Motion seam into the movement integrator (#162.4); routes to the command actor's queue (or the local player outside a command), so a command's impulse lands on whoever ran it. See `MotionIntents`. */
272
305
  motion: MotionIntents;
306
+ /** A specific player's motion queue — how the host-side per-player movement integrator drains each connected player's impulses. */
307
+ motionFor(userId: string): MotionIntents;
273
308
  };
274
309
  item: {
275
310
  use: GameContextItemUse;
@@ -282,5 +317,13 @@ export interface GameContext {
282
317
  input: InputSnapshot;
283
318
  subscribe(listener: () => void): () => void;
284
319
  version(): number;
320
+ /**
321
+ * Gather every opted-in live subsystem into one {@link WorldSnapshot} — entities, entity stats, the
322
+ * keyed store, the action feed, plus leaderboard/chat when those features are on. The full-world
323
+ * baseline a host sends a joining client; {@link hydrate} is its inverse.
324
+ */
325
+ snapshot(): WorldSnapshot;
326
+ /** Apply a {@link WorldSnapshot} from an authoritative host, hydrating each subsystem key present in it. */
327
+ hydrate(snapshot: WorldSnapshot): void;
285
328
  }
286
329
  export declare function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext;
@@ -24,11 +24,13 @@ import { createItemUse, } from "../item/use.js";
24
24
  import { createWeaponStats } from "../item/weapon.js";
25
25
  import { createPoseState } from "../movement/poseState.js";
26
26
  import { createPaintLayer } from "../scene/paintLayer.js";
27
- import { createEntityStatsApi, seedStatValues, setStatValue, } from "../scene/entityStats.js";
27
+ import { createEntityStatsApi, hydrateEntityStats, seedStatValues, setStatValue, snapshotEntityStats, } from "../scene/entityStats.js";
28
28
  import { createForms } from "../scene/form.js";
29
+ import { scaledObjectColliders } from "../scene/colliders.js";
29
30
  import { raycastObjects, raycastObjectsAll } from "../scene/objectQuery.js";
30
- import { createObjectStore } from "../scene/objectStore.js";
31
+ import { createObjectStore, objectVisualScale } from "../scene/objectStore.js";
31
32
  import { createRoster } from "../scene/roster.js";
33
+ import { createConnectedPlayers } from "../game/connectedPlayers.js";
32
34
  import { createPossession } from "../scene/possession.js";
33
35
  import { createSceneRaycast, } from "../scene/sceneRaycast.js";
34
36
  import { createSpatialApi } from "../scene/spatial.js";
@@ -36,6 +38,7 @@ import { createTargeting } from "../scene/targeting.js";
36
38
  import { createStats } from "../stats/statModifiers.js";
37
39
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
38
40
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
41
+ import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
39
42
  import { createSimClock } from "../time/simClock.js";
40
43
  import { createTurnLoop } from "../turn/turnLoop.js";
41
44
  import { RaceState } from "../game/race.js";
@@ -47,6 +50,8 @@ export function createGameContext(options) {
47
50
  const now = options.now ?? Date.now;
48
51
  const occluder = options.occluder;
49
52
  const signal = createChangeSignal();
53
+ let actingUserId = null;
54
+ const activeUserId = () => actingUserId ?? player.userId;
50
55
  const time = createSimClock({ config: definition.time, onChange: signal.notify });
51
56
  const ground = groundFieldFor(definition.world);
52
57
  const entities = definition.scene;
@@ -107,7 +112,15 @@ export function createGameContext(options) {
107
112
  const override = objectColliders.get(instanceId);
108
113
  if (override !== undefined)
109
114
  return override;
110
- return catalogObject(instanceId)?.colliders ?? null;
115
+ const catalog = catalogObject(instanceId);
116
+ if (catalog?.colliders !== undefined)
117
+ return catalog.colliders;
118
+ if (catalog?.halfExtents !== undefined)
119
+ return null;
120
+ const object = objects.get(instanceId);
121
+ if (object === null || object.visual?.scale === undefined)
122
+ return null;
123
+ return scaledObjectColliders(objectVisualScale(object.visual));
111
124
  }
112
125
  const targeting = notifyAfter(createTargeting({
113
126
  candidates: () => [...refreshCandidateIds()],
@@ -169,34 +182,55 @@ export function createGameContext(options) {
169
182
  const feed = createGameFeed(definition.feed);
170
183
  const lootRegistry = createLootRegistry();
171
184
  const unlocks = notifyAfter(createUnlocks(), ["grant", "hydrate"], signal.notify);
172
- const rawSocial = createSocial({
173
- events,
174
- now,
175
- emotes: {
176
- entities: { get: (id) => entities.get(id) },
177
- spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
178
- },
179
- });
180
- const social = {
181
- friends: notifyAfter(rawSocial.friends, ["request", "accept", "decline", "remove", "block", "hydrate"], signal.notify),
182
- party: notifyAfter(rawSocial.party, ["invite", "accept", "decline", "kick", "leave", "promote"], signal.notify),
183
- presence: rawSocial.presence,
184
- emotes: rawSocial.emotes,
185
- worldInvites: notifyAfter(rawSocial.worldInvites, ["invite", "accept", "decline"], signal.notify),
186
- };
187
- const chat = notifyAfter(createChat({
188
- events,
189
- now,
190
- party: rawSocial.party,
191
- proximity: {
192
- entities: { get: (id) => entities.get(id) },
193
- spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
194
- },
195
- blockedBy: (userId) => rawSocial.friends.snapshot(userId).blocked,
196
- }), ["register", "send", "whisper", "hydrate"], signal.notify);
197
- const leaderboard = notifyAfter(createLeaderboard(), ["increment", "hydrate"], signal.notify);
198
- const roster = notifyAfter(createRoster({ now }), ["capture", "release", "setEquipped", "hydrate"], signal.notify);
199
- const playerStats = createStats({});
185
+ const features = definition.features ?? {};
186
+ const rawSocial = features.social || features.chat
187
+ ? createSocial({
188
+ events,
189
+ now,
190
+ emotes: {
191
+ entities: { get: (id) => entities.get(id) },
192
+ spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
193
+ },
194
+ })
195
+ : null;
196
+ let social;
197
+ if (features.social && rawSocial !== null) {
198
+ social = {
199
+ friends: notifyAfter(rawSocial.friends, ["request", "accept", "decline", "remove", "block", "hydrate"], signal.notify),
200
+ party: notifyAfter(rawSocial.party, ["invite", "accept", "decline", "kick", "leave", "promote"], signal.notify),
201
+ presence: rawSocial.presence,
202
+ emotes: rawSocial.emotes,
203
+ worldInvites: notifyAfter(rawSocial.worldInvites, ["invite", "accept", "decline"], signal.notify),
204
+ };
205
+ }
206
+ let chat;
207
+ if (features.chat && rawSocial !== null) {
208
+ chat = notifyAfter(createChat({
209
+ events,
210
+ now,
211
+ party: rawSocial.party,
212
+ proximity: {
213
+ entities: { get: (id) => entities.get(id) },
214
+ spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
215
+ },
216
+ blockedBy: (userId) => rawSocial.friends.snapshot(userId).blocked,
217
+ }), ["register", "send", "whisper", "hydrate"], signal.notify);
218
+ }
219
+ const leaderboard = features.leaderboard
220
+ ? notifyAfter(createLeaderboard(), ["increment", "hydrate"], signal.notify)
221
+ : undefined;
222
+ const roster = features.roster
223
+ ? notifyAfter(createRoster({ now }), ["capture", "release", "setEquipped", "hydrate"], signal.notify)
224
+ : undefined;
225
+ const playerStatsByUser = new Map();
226
+ function playerStatsFor(userId) {
227
+ let stats = playerStatsByUser.get(userId);
228
+ if (stats === undefined) {
229
+ stats = createStats({});
230
+ playerStatsByUser.set(userId, stats);
231
+ }
232
+ return stats;
233
+ }
200
234
  const pose = createPoseState((instanceId) => catalogEntry(instanceId)?.movement);
201
235
  const commandRegistry = createCommandRegistry();
202
236
  const itemUse = createItemUse((itemId) => content.itemById?.(itemId)?.use);
@@ -211,7 +245,15 @@ export function createGameContext(options) {
211
245
  layouts[inventoryId] = { slots: declaration.slots, accepts: declaration.accepts };
212
246
  }
213
247
  const traits = Object.values(inventoryDeclarations).find((declaration) => declaration.traits !== undefined)?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY };
214
- const inventory = notifyAfter(createInventorySet(layouts, traits), ["put", "take", "move", "replaceState"], signal.notify);
248
+ const inventoryByUser = new Map();
249
+ function inventoryFor(userId) {
250
+ let set = inventoryByUser.get(userId);
251
+ if (set === undefined) {
252
+ set = notifyAfter(createInventorySet(layouts, traits), ["put", "take", "move", "replaceState"], signal.notify);
253
+ inventoryByUser.set(userId, set);
254
+ }
255
+ return set;
256
+ }
215
257
  const wallets = new Map();
216
258
  const walletOf = (userId) => wallets.get(userId) ?? createEmptyWallet();
217
259
  const economy = {
@@ -229,7 +271,8 @@ export function createGameContext(options) {
229
271
  return null;
230
272
  },
231
273
  };
232
- function putIntoAnyInventory(itemId, count) {
274
+ function putIntoAnyInventory(userId, itemId, count) {
275
+ const inventory = inventoryFor(userId);
233
276
  for (const inventoryId of inventoryIds) {
234
277
  if (inventory.put(inventoryId, itemId, count).status === "ok")
235
278
  return;
@@ -241,7 +284,7 @@ export function createGameContext(options) {
241
284
  roll: lootRegistry.roll,
242
285
  grantToPlayer(userId, drops, source) {
243
286
  grantDrops(drops, {
244
- putItem: (itemId, count) => putIntoAnyInventory(itemId, count),
287
+ putItem: (itemId, count) => putIntoAnyInventory(userId, itemId, count),
245
288
  grantCurrency: (currencyId, amount) => economy.grant(userId, currencyId, amount),
246
289
  });
247
290
  const event = { userId, drops };
@@ -253,17 +296,17 @@ export function createGameContext(options) {
253
296
  const trade = createTradeSystem({
254
297
  resolveTrade: (itemId) => content.itemById?.(itemId)?.trade,
255
298
  wallet: {
256
- canAfford: (costs) => (walletCanAfford(walletOf(player.userId), costs) ? null : "insufficient-funds"),
299
+ canAfford: (costs) => (walletCanAfford(walletOf(activeUserId()), costs) ? null : "insufficient-funds"),
257
300
  charge(costs) {
258
- const result = walletChargeAll(walletOf(player.userId), costs);
301
+ const result = walletChargeAll(walletOf(activeUserId()), costs);
259
302
  if (result.status === "ok") {
260
- wallets.set(player.userId, result.state);
303
+ wallets.set(activeUserId(), result.state);
261
304
  signal.notify();
262
305
  }
263
306
  },
264
307
  grant(gains) {
265
308
  for (const [currencyId, amount] of Object.entries(gains)) {
266
- economy.grant(player.userId, currencyId, amount);
309
+ economy.grant(activeUserId(), currencyId, amount);
267
310
  }
268
311
  },
269
312
  },
@@ -271,16 +314,16 @@ export function createGameContext(options) {
271
314
  put(inventoryId, itemId, count) {
272
315
  if (layouts[inventoryId] === undefined)
273
316
  return { reason: `unknown inventory "${inventoryId}"` };
274
- const result = inventory.put(inventoryId, itemId, count);
317
+ const result = inventoryFor(activeUserId()).put(inventoryId, itemId, count);
275
318
  return result.status === "ok" ? null : { reason: result.reason };
276
319
  },
277
320
  take(inventoryId, itemId, count) {
278
321
  if (layouts[inventoryId] === undefined)
279
322
  return { reason: `unknown inventory "${inventoryId}"` };
280
- const result = inventory.take(inventoryId, itemId, count);
323
+ const result = inventoryFor(activeUserId()).take(inventoryId, itemId, count);
281
324
  return result.status === "ok" ? null : { reason: result.reason };
282
325
  },
283
- count: (inventoryId, itemId) => inventory.count(inventoryId, itemId),
326
+ count: (inventoryId, itemId) => inventoryFor(activeUserId()).count(inventoryId, itemId),
284
327
  },
285
328
  });
286
329
  function seedUserPool(userId, statId, pool) {
@@ -298,11 +341,9 @@ export function createGameContext(options) {
298
341
  },
299
342
  grantEconomy: (userId, currencyId, amount) => economy.grant(userId, currencyId, amount),
300
343
  grantItem(userId, inventoryId, itemId, count) {
301
- if (userId !== player.userId)
302
- return { reason: `unknown user "${userId}"` };
303
344
  if (layouts[inventoryId] === undefined)
304
345
  return { reason: `unknown inventory "${inventoryId}"` };
305
- const result = inventory.put(inventoryId, itemId, count);
346
+ const result = inventoryFor(userId).put(inventoryId, itemId, count);
306
347
  return result.status === "ok" ? null : { reason: result.reason };
307
348
  },
308
349
  grantUnlock: (userId, unlockId) => unlocks.grant(userId, unlockId),
@@ -312,7 +353,8 @@ export function createGameContext(options) {
312
353
  const quest = notifyAfter(rawQuest, ["accept", "abandon", "progress", "turnIn", "grant", "revoke", "hydrate"], signal.notify);
313
354
  const loadouts = notifyAfter(createLoadouts({
314
355
  inventory: {
315
- begin() {
356
+ begin(userId) {
357
+ const inventory = inventoryFor(userId);
316
358
  const staged = new Map();
317
359
  return {
318
360
  put(inventoryId, itemId, count, slot) {
@@ -646,7 +688,10 @@ export function createGameContext(options) {
646
688
  },
647
689
  collidersOf: objectCollidersOf,
648
690
  };
649
- const store = notifyAfter(createObservableKeyedStore(), ["set", "delete"], signal.notify);
691
+ const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
692
+ const players = features.players
693
+ ? notifyAfter(createConnectedPlayers(), ["join", "leave"], signal.notify)
694
+ : undefined;
650
695
  const cardPiles = new Map();
651
696
  function pile(id, config) {
652
697
  const existing = cardPiles.get(id);
@@ -725,7 +770,63 @@ export function createGameContext(options) {
725
770
  }
726
771
  const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
727
772
  const input = createInputSnapshot();
728
- const motion = createMotionIntents();
773
+ const motionByUser = new Map();
774
+ function motionFor(userId) {
775
+ let queue = motionByUser.get(userId);
776
+ if (queue === undefined) {
777
+ queue = createMotionIntents();
778
+ motionByUser.set(userId, queue);
779
+ }
780
+ return queue;
781
+ }
782
+ const snapshotModules = [
783
+ { key: "entities", snapshot: () => entities.snapshot(), hydrate: (data) => entities.hydrate(data) },
784
+ {
785
+ key: "stats",
786
+ snapshot: () => snapshotEntityStats(statsByInstance),
787
+ hydrate: (data) => hydrateEntityStats(statsByInstance, data),
788
+ },
789
+ {
790
+ key: "store",
791
+ snapshot: () => store.snapshot(),
792
+ hydrate: (data) => store.hydrate(data),
793
+ },
794
+ { key: "feed", snapshot: () => feed.snapshot(), hydrate: (data) => feed.hydrate(data) },
795
+ {
796
+ key: "inventory",
797
+ snapshot: () => {
798
+ const byUser = {};
799
+ for (const [userId, set] of inventoryByUser) {
800
+ const states = {};
801
+ for (const inventoryId of inventoryIds)
802
+ states[inventoryId] = set.state(inventoryId);
803
+ byUser[userId] = states;
804
+ }
805
+ return byUser;
806
+ },
807
+ hydrate: (data) => {
808
+ for (const [userId, states] of Object.entries(data)) {
809
+ const set = inventoryFor(userId);
810
+ for (const [inventoryId, state] of Object.entries(states))
811
+ set.replaceState(inventoryId, state);
812
+ }
813
+ },
814
+ },
815
+ ];
816
+ if (leaderboard) {
817
+ snapshotModules.push({
818
+ key: "leaderboard",
819
+ snapshot: () => leaderboard.snapshot(),
820
+ hydrate: (data) => leaderboard.hydrate(data),
821
+ });
822
+ }
823
+ if (chat) {
824
+ snapshotModules.push({
825
+ key: "chat",
826
+ snapshot: () => chat.snapshot(),
827
+ hydrate: (data) => chat.hydrate(data),
828
+ });
829
+ }
729
830
  const ctx = {
730
831
  scene: {
731
832
  object: sceneObjects,
@@ -740,6 +841,7 @@ export function createGameContext(options) {
740
841
  spawnPoseOf: entities.spawnPoseOf,
741
842
  resetToSpawn: entities.resetToSpawn,
742
843
  resetAllToSpawn,
844
+ blackboard: entities.blackboard,
743
845
  stats: entityStats,
744
846
  floatText: emitFloatText,
745
847
  telegraph: fireTelegraph,
@@ -795,8 +897,27 @@ export function createGameContext(options) {
795
897
  signal.notify();
796
898
  return result;
797
899
  },
900
+ runAs(actorUserId, name, input) {
901
+ const previous = actingUserId;
902
+ actingUserId = actorUserId;
903
+ try {
904
+ const result = commandRegistry.run(ctx, name, input);
905
+ signal.notify();
906
+ return result;
907
+ }
908
+ finally {
909
+ actingUserId = previous;
910
+ }
911
+ },
912
+ actor: () => actingUserId,
798
913
  },
799
914
  events,
915
+ playEntityAnimation: (instanceId, event) => events.emit("entity.animation", { instanceId, event }),
916
+ audio: {
917
+ play: (sound, at) => events.emit("audio.play", at === undefined ? { sound } : { sound, at }),
918
+ music: (theme, transpose) => events.emit("audio.music", transpose === undefined ? { theme } : { theme, transpose }),
919
+ resume: () => events.emit("audio.resume", {}),
920
+ },
800
921
  feed: {
801
922
  bind: (action) => feed.bind(action, events),
802
923
  push(action, entry) {
@@ -821,21 +942,33 @@ export function createGameContext(options) {
821
942
  leaderboard,
822
943
  roster,
823
944
  store,
824
- cards: { pile },
825
- turn: { loop },
826
- race: { state: raceState },
945
+ cards: features.cards ? { pile } : undefined,
946
+ turn: features.turn ? { loop } : undefined,
947
+ race: features.race ? { state: raceState } : undefined,
948
+ players,
827
949
  },
828
950
  player: {
829
- userId: player.userId,
951
+ get userId() {
952
+ return activeUserId();
953
+ },
830
954
  isNew: player.isNew,
831
- inventory,
832
- stats: playerStats,
955
+ get inventory() {
956
+ return inventoryFor(activeUserId());
957
+ },
958
+ inventoryFor,
959
+ get stats() {
960
+ return playerStatsFor(activeUserId());
961
+ },
962
+ statsFor: playerStatsFor,
833
963
  loadout: loadouts,
834
964
  applyLoadout: loadouts.applyLoadout,
835
965
  movement: pose,
836
966
  possession,
837
967
  cosmetics,
838
- motion,
968
+ get motion() {
969
+ return motionFor(activeUserId());
970
+ },
971
+ motionFor,
839
972
  },
840
973
  item: {
841
974
  use: {
@@ -855,6 +988,11 @@ export function createGameContext(options) {
855
988
  input,
856
989
  subscribe: signal.subscribe,
857
990
  version: signal.version,
991
+ snapshot: () => composeWorldSnapshot(snapshotModules),
992
+ hydrate(snapshot) {
993
+ applyWorldSnapshot(snapshotModules, snapshot);
994
+ signal.notify();
995
+ },
858
996
  };
859
997
  return ctx;
860
998
  }
@@ -0,0 +1,49 @@
1
+ import type { CommandResult } from "../commands/commandRegistry.js";
2
+ import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
3
+ import type { ModelAssetRef } from "../scene/assetCatalog.js";
4
+ import { type GameContext, type GameContextContent } from "./gameContext.js";
5
+ import { type InputFrame } from "./inputSnapshot.js";
6
+ import { type WorldDiff } from "./worldReplication.js";
7
+ import type { WorldSnapshot } from "./worldSnapshot.js";
8
+ export type { InputFrame };
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. */
10
+ export declare const INPUT_COMMAND = "engine.input";
11
+ /** Config for {@link createHostedGameRunner}: the game definition, its content lookup, and an optional host identity. */
12
+ export interface HostedGameRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> {
13
+ definition: GameDefinition<TAssetRef, TMultiplayer>;
14
+ content: GameContextContent;
15
+ /** The world's own authoritative identity (`ctx.player` server-side); real players join as members. */
16
+ host?: LoopPlayer;
17
+ now?: () => number;
18
+ /**
19
+ * Rehydrate a persisted world instead of booting a fresh one — for stateless hosts (Convex) that reconstruct
20
+ * the runner each invocation. `onInit` still runs (so commands and systems it registers exist), then this
21
+ * snapshot overlays the world state it seeded. Omit for a long-lived stateful host (ws) that keeps one runner.
22
+ */
23
+ restore?: WorldSnapshot;
24
+ }
25
+ /**
26
+ * The GameContext-loop equivalent of the pure-reducer `createGameHost`: one authoritative `createGameContext`
27
+ * per world, driven server-side. `onInit` runs once at construction; `onNewPlayer`/`onPlayerLeave` fire per
28
+ * join/leave; `tick` advances `onTick` then commits a revision. Clients pull a full {@link WorldSnapshot}
29
+ * baseline once, then per-tick {@link WorldDiff}s from their last revision. Games ship only normal GameContext
30
+ * code — the runner adds no per-game surface.
31
+ */
32
+ export interface HostedGameRunner {
33
+ join(userId: string, isNew: boolean): void;
34
+ /** Re-attach an already-joined member on a reconstructed runner — restores membership and `ctx.game.players` without re-firing `onNewPlayer`. Stateless hosts call it once per persisted member after `restore`. */
35
+ resume(userId: string): void;
36
+ leave(userId: string): void;
37
+ /** Record a client's latest input frame — stashed for {@link heldInput} and mirrored onto `ctx.game.players` so the movement seam reads each player's intent in `onTick`. */
38
+ input(userId: string, frame: InputFrame): void;
39
+ heldInput(userId: string): InputFrame | null;
40
+ command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
41
+ tick(dt: number): number;
42
+ diff(sinceRevision: number): WorldDiff;
43
+ revision(): number;
44
+ snapshot(): WorldSnapshot;
45
+ members(): readonly string[];
46
+ context(): GameContext;
47
+ }
48
+ /** Build a {@link HostedGameRunner} — one authoritative GameContext world driven server-side from the game's own loop. */
49
+ export declare function createHostedGameRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedGameRunnerOptions<TAssetRef, TMultiplayer>): HostedGameRunner;
@@ -0,0 +1,62 @@
1
+ import { createGameContext } from "./gameContext.js";
2
+ import {} from "./inputSnapshot.js";
3
+ import { createWorldReplicator } from "./worldReplication.js";
4
+ /** 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. */
5
+ export const INPUT_COMMAND = "engine.input";
6
+ /** Build a {@link HostedGameRunner} — one authoritative GameContext world driven server-side from the game's own loop. */
7
+ export function createHostedGameRunner(options) {
8
+ const { definition, content, host, now, restore } = options;
9
+ const ctx = createGameContext({
10
+ definition,
11
+ content,
12
+ player: host ?? { userId: "host", isNew: true },
13
+ ...(now === undefined ? {} : { now }),
14
+ });
15
+ const loop = definition.loop ?? {};
16
+ const replicator = createWorldReplicator(() => ctx.snapshot());
17
+ const members = new Map();
18
+ const inputs = new Map();
19
+ loop.onInit?.(ctx);
20
+ if (restore !== undefined)
21
+ ctx.hydrate(restore);
22
+ return {
23
+ join(userId, isNew) {
24
+ const player = { userId, isNew };
25
+ members.set(userId, player);
26
+ ctx.game.players?.join(userId, isNew);
27
+ loop.onNewPlayer?.(ctx, player);
28
+ },
29
+ resume(userId) {
30
+ members.set(userId, { userId, isNew: false });
31
+ ctx.game.players?.join(userId, false);
32
+ },
33
+ leave(userId) {
34
+ const player = members.get(userId);
35
+ if (player === undefined)
36
+ return;
37
+ members.delete(userId);
38
+ inputs.delete(userId);
39
+ loop.onPlayerLeave?.(ctx, player);
40
+ ctx.game.players?.leave(userId);
41
+ },
42
+ input(userId, frame) {
43
+ inputs.set(userId, frame);
44
+ ctx.game.players?.setInput(userId, frame);
45
+ },
46
+ heldInput(userId) {
47
+ return inputs.get(userId) ?? null;
48
+ },
49
+ command(userId, name, input) {
50
+ return ctx.game.commands.runAs(userId, name, input);
51
+ },
52
+ tick(dt) {
53
+ loop.onTick?.(ctx, dt);
54
+ return replicator.commit();
55
+ },
56
+ diff: (sinceRevision) => replicator.diff(sinceRevision),
57
+ revision: () => replicator.revision(),
58
+ snapshot: () => ctx.snapshot(),
59
+ members: () => Array.from(members.keys()),
60
+ context: () => ctx,
61
+ };
62
+ }