@jgengine/core 0.18.1 → 0.19.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 (220) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/ai/decisionGraph.d.ts +48 -4
  3. package/dist/ai/decisionGraph.js +153 -23
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/pursuit.d.ts +1 -1
  7. package/dist/ai/pursuit.js +1 -1
  8. package/dist/ai/threat.d.ts +8 -0
  9. package/dist/ai/threat.js +10 -0
  10. package/dist/anim/animGraph.d.ts +11 -0
  11. package/dist/anim/animGraph.js +128 -0
  12. package/dist/anim/boneTexture.d.ts +55 -0
  13. package/dist/anim/boneTexture.js +43 -0
  14. package/dist/anim/footPlacement.d.ts +57 -0
  15. package/dist/anim/footPlacement.js +67 -0
  16. package/dist/anim/locomotionGraph.d.ts +9 -0
  17. package/dist/anim/locomotionGraph.js +29 -0
  18. package/dist/audio/audioFalloff.d.ts +2 -0
  19. package/dist/audio/doppler.d.ts +21 -0
  20. package/dist/audio/doppler.js +32 -0
  21. package/dist/audio/engineLayers.d.ts +83 -0
  22. package/dist/audio/engineLayers.js +115 -0
  23. package/dist/combat/abilityKit.d.ts +1 -0
  24. package/dist/combat/abilityKit.js +1 -0
  25. package/dist/combat/death.d.ts +7 -0
  26. package/dist/combat/shotOrigin.d.ts +6 -0
  27. package/dist/combat/shotOrigin.js +6 -0
  28. package/dist/combat/weaponFeedback.d.ts +36 -0
  29. package/dist/combat/weaponFeedback.js +42 -0
  30. package/dist/combat/weaponHandling.d.ts +161 -0
  31. package/dist/combat/weaponHandling.js +202 -0
  32. package/dist/combat/weaponPresentation.d.ts +115 -0
  33. package/dist/combat/weaponPresentation.js +96 -0
  34. package/dist/devtools/warnOnce.d.ts +8 -0
  35. package/dist/devtools/warnOnce.js +18 -0
  36. package/dist/editor/document.js +3 -1
  37. package/dist/editor/environment.js +8 -0
  38. package/dist/editor/types.d.ts +4 -0
  39. package/dist/faction/reputation.d.ts +6 -0
  40. package/dist/faction/reputation.js +11 -0
  41. package/dist/game/cameraConfig.d.ts +51 -1
  42. package/dist/game/events.d.ts +6 -0
  43. package/dist/game/lootTable.d.ts +4 -0
  44. package/dist/game/lootTable.js +16 -0
  45. package/dist/game/playableGame.d.ts +84 -17
  46. package/dist/game/toasts.d.ts +8 -0
  47. package/dist/game/toasts.js +7 -0
  48. package/dist/game/vfxInstance.d.ts +9 -0
  49. package/dist/game/vfxInstance.js +12 -1
  50. package/dist/game/viewports.d.ts +43 -0
  51. package/dist/game/viewports.js +46 -0
  52. package/dist/game/worldItem.d.ts +5 -1
  53. package/dist/game/worldItem.js +0 -1
  54. package/dist/input/actionBindings.d.ts +15 -1
  55. package/dist/input/actionBindings.js +14 -3
  56. package/dist/input/actionContexts.d.ts +25 -1
  57. package/dist/input/actionContexts.js +82 -25
  58. package/dist/input/axisInput.d.ts +4 -0
  59. package/dist/input/axisInput.js +4 -0
  60. package/dist/input/axisShaper.d.ts +73 -0
  61. package/dist/input/axisShaper.js +105 -0
  62. package/dist/input/gamepadModel.d.ts +36 -2
  63. package/dist/input/gamepadModel.js +69 -30
  64. package/dist/input/haptics.d.ts +50 -0
  65. package/dist/input/haptics.js +74 -0
  66. package/dist/input/inputBuffer.d.ts +40 -0
  67. package/dist/input/inputBuffer.js +59 -0
  68. package/dist/input/lookChannel.d.ts +10 -0
  69. package/dist/input/lookChannel.js +6 -0
  70. package/dist/input/pointer.d.ts +2 -0
  71. package/dist/input/pointer.js +4 -0
  72. package/dist/item/affix.d.ts +6 -0
  73. package/dist/item/affix.js +6 -0
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +112 -1
  76. package/dist/movement/glideModel.d.ts +3 -0
  77. package/dist/movement/glideModel.js +6 -0
  78. package/dist/movement/grappleSwing.d.ts +2 -0
  79. package/dist/movement/grappleSwing.js +5 -1
  80. package/dist/movement/movementModel.d.ts +39 -0
  81. package/dist/movement/movementModel.js +62 -19
  82. package/dist/movement/movementProbe.d.ts +51 -0
  83. package/dist/movement/movementProbe.js +119 -0
  84. package/dist/movement/playerMovement.d.ts +21 -2
  85. package/dist/movement/playerMovement.js +89 -6
  86. package/dist/movement/solidObstacles.d.ts +9 -1
  87. package/dist/movement/solidObstacles.js +44 -2
  88. package/dist/movement/voxelController.js +7 -7
  89. package/dist/nav/navFromEnvironment.d.ts +9 -3
  90. package/dist/nav/navFromEnvironment.js +18 -12
  91. package/dist/nav/navMesh.d.ts +43 -2
  92. package/dist/nav/navMesh.js +534 -128
  93. package/dist/physics/aircraftDynamics.d.ts +390 -0
  94. package/dist/physics/aircraftDynamics.js +704 -0
  95. package/dist/physics/boatDynamics.d.ts +116 -0
  96. package/dist/physics/boatDynamics.js +160 -0
  97. package/dist/physics/drivableVehicle.d.ts +22 -8
  98. package/dist/physics/drivableVehicle.js +4 -2
  99. package/dist/physics/flightDynamics.d.ts +21 -4
  100. package/dist/physics/flightDynamics.js +28 -5
  101. package/dist/physics/handlingProbe.d.ts +249 -0
  102. package/dist/physics/handlingProbe.js +606 -0
  103. package/dist/physics/kinematicVehicle.d.ts +32 -2
  104. package/dist/physics/kinematicVehicle.js +15 -0
  105. package/dist/physics/physicsBackend.d.ts +12 -0
  106. package/dist/physics/physicsBackendConformance.js +34 -0
  107. package/dist/physics/physicsWorldBackend.d.ts +1 -0
  108. package/dist/physics/physicsWorldBackend.js +33 -0
  109. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  110. package/dist/physics/vehicleBackendLink.js +102 -0
  111. package/dist/physics/vehicleDynamics.d.ts +374 -0
  112. package/dist/physics/vehicleDynamics.js +737 -0
  113. package/dist/physics/worldColliders.d.ts +1 -1
  114. package/dist/physics/worldColliders.js +35 -3
  115. package/dist/puzzle/cellGrid.d.ts +15 -9
  116. package/dist/puzzle/cellGrid.js +13 -9
  117. package/dist/render/lookPreset.d.ts +9 -2
  118. package/dist/render/lookPreset.js +29 -2
  119. package/dist/render/postProcessing.d.ts +30 -0
  120. package/dist/render/postProcessing.js +1 -1
  121. package/dist/runtime/cameraDirector.d.ts +17 -3
  122. package/dist/runtime/cameraDirector.js +25 -0
  123. package/dist/runtime/commandInput.d.ts +37 -0
  124. package/dist/runtime/commandInput.js +58 -0
  125. package/dist/runtime/commandRunner.d.ts +27 -0
  126. package/dist/runtime/commandRunner.js +31 -3
  127. package/dist/runtime/context/combat.d.ts +1 -0
  128. package/dist/runtime/context/combat.js +2 -1
  129. package/dist/runtime/context/deathLoot.d.ts +2 -0
  130. package/dist/runtime/context/deathLoot.js +1 -0
  131. package/dist/runtime/gameContext.js +7 -0
  132. package/dist/runtime/gameContextTypes.d.ts +19 -3
  133. package/dist/runtime/gameRuntime.d.ts +3 -1
  134. package/dist/runtime/gameRuntime.js +4 -0
  135. package/dist/runtime/inputSnapshot.d.ts +6 -0
  136. package/dist/runtime/inputSnapshot.js +10 -0
  137. package/dist/runtime/localPlayers.d.ts +73 -0
  138. package/dist/runtime/localPlayers.js +119 -0
  139. package/dist/runtime/transport.d.ts +9 -0
  140. package/dist/runtime/transport.js +7 -1
  141. package/dist/scene/behaviorRuntime.d.ts +12 -2
  142. package/dist/scene/behaviorRuntime.js +30 -14
  143. package/dist/scene/behaviors.d.ts +5 -1
  144. package/dist/scene/sceneKinds.d.ts +9 -0
  145. package/dist/scene/vehicleSeat.d.ts +5 -1
  146. package/dist/scene/vehicleSeat.js +5 -1
  147. package/dist/sensor/concealment.d.ts +6 -0
  148. package/dist/sensor/concealment.js +8 -0
  149. package/dist/sensor/freezeMonitor.d.ts +6 -0
  150. package/dist/sensor/freezeMonitor.js +8 -0
  151. package/dist/sensor/perception.d.ts +11 -1
  152. package/dist/sensor/perception.js +57 -10
  153. package/dist/sensor/recordingBuffer.d.ts +3 -0
  154. package/dist/sensor/recordingBuffer.js +8 -0
  155. package/dist/session/roundState.d.ts +4 -0
  156. package/dist/session/roundState.js +11 -0
  157. package/dist/settings/frameRateLimit.d.ts +16 -0
  158. package/dist/settings/frameRateLimit.js +37 -0
  159. package/dist/settings/graphicsProfile.d.ts +2 -0
  160. package/dist/settings/graphicsProfile.js +12 -0
  161. package/dist/settings/graphicsSettings.d.ts +40 -0
  162. package/dist/settings/graphicsSettings.js +81 -0
  163. package/dist/settings/settingsModel.d.ts +9 -1
  164. package/dist/settings/settingsModel.js +10 -0
  165. package/dist/stats/eventMeter.d.ts +1 -1
  166. package/dist/stats/eventMeter.js +1 -1
  167. package/dist/vfx/feedbackMixer.d.ts +61 -0
  168. package/dist/vfx/feedbackMixer.js +106 -0
  169. package/dist/visibility/simulationCulling.d.ts +8 -1
  170. package/dist/visibility/simulationCulling.js +7 -0
  171. package/dist/visibility/spatialIndex.d.ts +21 -0
  172. package/dist/visibility/spatialIndex.js +16 -0
  173. package/dist/world/authoredSolids.d.ts +46 -0
  174. package/dist/world/authoredSolids.js +75 -0
  175. package/dist/world/buildingGenerator.js +2 -2
  176. package/dist/world/buildingKit.d.ts +3 -1
  177. package/dist/world/buildings.d.ts +34 -2
  178. package/dist/world/buildings.js +11 -1
  179. package/dist/world/cityKind.d.ts +7 -0
  180. package/dist/world/cityKind.js +52 -1
  181. package/dist/world/features.d.ts +29 -2
  182. package/dist/world/features.js +6 -0
  183. package/dist/world/footprintGrid.d.ts +7 -0
  184. package/dist/world/footprintGrid.js +16 -0
  185. package/dist/world/interiors.d.ts +1 -0
  186. package/dist/world/interiors.js +5 -3
  187. package/dist/world/lod.d.ts +6 -0
  188. package/dist/world/lod.js +8 -0
  189. package/dist/world/terraform.d.ts +8 -0
  190. package/dist/world/terraform.js +7 -0
  191. package/dist/world/walls.d.ts +7 -0
  192. package/dist/world/walls.js +7 -0
  193. package/dist/world/worldSolids.d.ts +84 -0
  194. package/dist/world/worldSolids.js +240 -0
  195. package/dist/world.d.ts +2 -2
  196. package/dist/world.js +1 -1
  197. package/package.json +1 -1
  198. package/skills/ce-handoff/SKILL.md +1 -1
  199. package/skills/game-design/SKILL.md +2 -2
  200. package/skills/jgengine/SKILL.md +7 -5
  201. package/skills/jgengine/api.md +118 -89
  202. package/skills/jgengine/capabilities.md +28 -0
  203. package/skills/jgengine/recipes/minimal-game.md +3 -0
  204. package/skills/jgengine-combat/SKILL.md +5 -0
  205. package/skills/jgengine-combat/api.md +138 -103
  206. package/skills/jgengine-combat/capabilities.md +29 -1
  207. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  208. package/skills/jgengine-gameplay/api.md +462 -436
  209. package/skills/jgengine-gameplay/capabilities.md +24 -0
  210. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  211. package/skills/jgengine-world/SKILL.md +15 -3
  212. package/skills/jgengine-world/api.md +873 -626
  213. package/skills/jgengine-world/capabilities.md +130 -6
  214. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  215. package/skills/jgengine-world/recipes/controls.md +44 -0
  216. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  217. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  218. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  219. package/skills/jgengine-world/reference.md +31 -2
  220. package/skills/jgengine-world/visibility.md +112 -0
@@ -14,6 +14,7 @@ export function applyLethalLoot(input) {
14
14
  const resolved = resolveDeathDrops([...input.drops], {
15
15
  mode: "world",
16
16
  origin: input.position,
17
+ rng: input.rng,
17
18
  resolveRarity: (itemId) => input.content.itemById?.(itemId)?.rarity ?? DEFAULT_RARITY,
18
19
  resolveBaseType: (itemId) => input.content.itemById?.(itemId)?.baseType ?? itemId,
19
20
  scatter: normalizedOnDeath.scatter,
@@ -1,6 +1,7 @@
1
1
  import { createCommandRegistry } from "../commands/commandRegistry.js";
2
2
  import {} from "../game/cosmetics.js";
3
3
  import { groundFieldFor } from "../world/terrain.js";
4
+ import { createWorldSolids, structureSolids } from "../world/worldSolids.js";
4
5
  import { createGameEvents } from "../game/events.js";
5
6
  import { createGameFeed } from "../game/feed.js";
6
7
  import { setGamePhase } from "../game/gamePhase.js";
@@ -43,6 +44,10 @@ export function createGameContext(options) {
43
44
  const activeUserId = () => actingUserId ?? player.userId;
44
45
  const time = createSimClock({ config: definition.time, onChange: signal.notify });
45
46
  const ground = groundFieldFor(definition.world);
47
+ const solids = createWorldSolids();
48
+ if (definition.world?.kind === "environment") {
49
+ solids.set("environment:structures", structureSolids(definition.world, ground.sampleHeight));
50
+ }
46
51
  const rawEvents = createGameEvents();
47
52
  const events = {
48
53
  on: rawEvents.on,
@@ -215,6 +220,7 @@ export function createGameContext(options) {
215
220
  commandRegistry.run(ctxRef, name, args);
216
221
  },
217
222
  localUserId: player.userId,
223
+ rng,
218
224
  ...(definition.physics !== undefined ? { physics: definition.physics } : {}),
219
225
  });
220
226
  scene.setOnAfterSpawn((instanceId) => combat.death.revive(instanceId));
@@ -406,6 +412,7 @@ export function createGameContext(options) {
406
412
  world: {
407
413
  ground,
408
414
  groundHeightAt: ground.sampleHeight,
415
+ solids,
409
416
  },
410
417
  game: {
411
418
  territory: options.territory,
@@ -11,6 +11,7 @@ import type { ChargeOptions as WalletChargeOptions } from "../economy/wallet.js"
11
11
  import type { Cosmetics } from "../game/cosmetics.js";
12
12
  import type { GameDefinition } from "../game/defineGame.js";
13
13
  import type { TerrainField } from "../world/terrain.js";
14
+ import type { WorldSolids } from "../world/worldSolids.js";
14
15
  import type { GameEventMap, GameEvents, VfxKind } from "../game/events.js";
15
16
  import type { VfxInstanceStore } from "../game/vfxInstance.js";
16
17
  import type { GameFeed } from "../game/feed.js";
@@ -344,6 +345,11 @@ export type WorldItemPickupResult = {
344
345
  status: "rejected";
345
346
  reason: string;
346
347
  };
348
+ /**
349
+ * `ctx.scene.worldItem`: items lying in the world — spawn, list, find the nearest, and pick up into a player's bag.
350
+ *
351
+ * @capability world-item-pickup pick up dropped items: ctx.scene.worldItem nearestInRadius → pickup into the player's inventory
352
+ */
347
353
  export interface SceneWorldItemContext {
348
354
  spawn(input: WorldItemSpawnInput): WorldItemRecord;
349
355
  get(instanceId: string): WorldItemRecord | null;
@@ -390,6 +396,11 @@ export interface GameContextItemUse {
390
396
  export interface GameContextWorld {
391
397
  ground: TerrainField;
392
398
  groundHeightAt(x: number, z: number): number;
399
+ /**
400
+ * Static collision for world geometry that is not a scene object. `environment({ structures })`
401
+ * fills the `environment:structures` layer; `syncAuthoredSolids` fills one layer per studio object.
402
+ */
403
+ solids: WorldSolids;
393
404
  }
394
405
  export interface GameContextCards {
395
406
  /** Lazily creates (on first call, `config` required) or returns the existing notify-wrapped pile for `id`. */
@@ -420,14 +431,19 @@ export interface GameAudio {
420
431
  }): void;
421
432
  /**
422
433
  * Live-update retained loop `id` via `audio.loopSet`: `rate` re-pitches it (1 = authored pitch, clamped
423
- * 0.25–4 by the shell), `gain` rescales volume (0–1), `at` repositions its emitter. Cheap to call every
424
- * tick (~60 Hz) — the shell ramps rate/gain over ~20 ms to avoid zipper noise. A no-op when `id` is not a
425
- * live loop (an update may race a stop) (#1051).
434
+ * 0.25–4 by the shell), `gain` rescales volume (0–1), `at` repositions its emitter. `lowpass`/`highpass`
435
+ * set filter cutoffs in Hz (muffle an engine off-throttle or behind a wall); `velocity` is the emitter's
436
+ * world velocity, which pitches the loop by doppler when its sound declares `doppler`. Cheap to call every
437
+ * tick (~60 Hz) — the shell ramps rate/gain/cutoffs over ~20 ms to avoid zipper noise. A no-op when `id`
438
+ * is not a live loop (an update may race a stop) (#1051).
426
439
  */
427
440
  setLoop(id: string, params: {
428
441
  rate?: number;
429
442
  gain?: number;
430
443
  at?: readonly [number, number, number];
444
+ velocity?: readonly [number, number, number];
445
+ lowpass?: number;
446
+ highpass?: number;
431
447
  }): void;
432
448
  /** Stop and dispose retained loop `id` (emits `audio.loopStop`); an unknown `id` is ignored (#1051). */
433
449
  stopLoop(id: string): void;
@@ -1,5 +1,5 @@
1
1
  import type { SaveConfig } from "./save.js";
2
- import type { CommandDef, CommandScope } from "./commandRunner.js";
2
+ import type { CommandAccess, CommandDef, CommandScope } from "./commandRunner.js";
3
3
  import { runCommand } from "./commandRunner.js";
4
4
  import { type GameRuntimeSnapshot, type RuntimeChunkRow, type RuntimePlayerRow, type RuntimeProfileRow, type RuntimeServerRow } from "./snapshot.js";
5
5
  export type ServerLoopHooks = {
@@ -69,6 +69,8 @@ export type GameRuntime = {
69
69
  * and nothing else. Missing scopes load only the actor and no chunks; `{}` explicitly requests all state.
70
70
  */
71
71
  commandScope: (commandName: string, input: unknown, actorUserId: string) => CommandScope | undefined;
72
+ /** Who may send a command, or `undefined` when the runtime has no command by that name. */
73
+ commandAccess: (commandName: string) => CommandAccess | undefined;
72
74
  /** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */
73
75
  joinScope: (userId: string, isNew: boolean) => CommandScope | undefined;
74
76
  /** `nowMs` is the host wall clock at the start of the tick, in ms; defaults to `Date.now()`. */
@@ -38,6 +38,10 @@ export function createGameRuntime(definition) {
38
38
  commandScope(commandName, input, actorUserId) {
39
39
  return resolveCommandScope(definition.commands, commandName, input, actorUserId);
40
40
  },
41
+ commandAccess(commandName) {
42
+ const command = definition.commands[commandName];
43
+ return command === undefined ? undefined : (command.access ?? "client");
44
+ },
41
45
  joinScope(userId, isNew) {
42
46
  if (!loop?.onNewPlayer)
43
47
  return { players: [userId], chunkKeys: [] };
@@ -1,5 +1,6 @@
1
1
  import type { PointerAxisState } from "../input/pointerAxis.js";
2
2
  import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
3
+ import { type HapticChannels } from "../input/haptics.js";
3
4
  /** One client's input for a tick — the semantic held-action set plus pointer state, the serializable, over-the-wire counterpart of {@link InputSnapshot} the host stores per connected player. */
4
5
  export interface InputFrame {
5
6
  held: readonly string[];
@@ -16,6 +17,11 @@ export interface InputSnapshot {
16
17
  weak: number;
17
18
  ms: number;
18
19
  }): Promise<boolean>;
20
+ /**
21
+ * Continuous rumble channels for a local user (`engine`, `road`, `impact`, …): set levels each tick
22
+ * and the shell mixes them by priority onto that user's pad. Local only — never in a snapshot or on the wire.
23
+ */
24
+ haptics(userId: string): HapticChannels;
19
25
  /** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
20
26
  publish(held: readonly string[]): void;
21
27
  /** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
@@ -1,10 +1,12 @@
1
1
  import { sampleAxisBindings } from "../input/axisInput.js";
2
+ import { createHapticChannels } from "../input/haptics.js";
2
3
  export function createInputSnapshot() {
3
4
  let heldSet = new Set();
4
5
  let previousHeldSet = new Set();
5
6
  let heldList = [];
6
7
  let pointerState = null;
7
8
  let analogValues = null;
9
+ const haptics = new Map();
8
10
  const value = (action) => {
9
11
  const analog = analogValues?.[action];
10
12
  if (analog !== undefined)
@@ -13,6 +15,14 @@ export function createInputSnapshot() {
13
15
  };
14
16
  return {
15
17
  rumble: async () => false,
18
+ haptics(userId) {
19
+ let channels = haptics.get(userId);
20
+ if (channels === undefined) {
21
+ channels = createHapticChannels();
22
+ haptics.set(userId, channels);
23
+ }
24
+ return channels;
25
+ },
16
26
  publish(held) {
17
27
  previousHeldSet = heldSet;
18
28
  heldList = Object.freeze([...held]);
@@ -0,0 +1,73 @@
1
+ import type { GameContext } from "./gameContext.js";
2
+ import { type InputSnapshot } from "./inputSnapshot.js";
3
+ /** One local seat on a shared screen: a stable slot id, the user id the game spawns for it, and the device driving it. */
4
+ export interface LocalPlayerSlot {
5
+ readonly slotId: string;
6
+ readonly index: number;
7
+ readonly userId: string;
8
+ /** Device that claimed the seat (`"gamepad:1"`), `null` for the primary seat before a device claims it. */
9
+ readonly deviceId: string | null;
10
+ }
11
+ /** Retunable seat policy. */
12
+ export interface LocalPlayersConfig {
13
+ /** Seats on this screen, including the primary (default `1`: every device drives the primary player). */
14
+ maxSlots: number;
15
+ /**
16
+ * Whether the first device to press something claims the primary seat alongside keyboard and touch
17
+ * (`"first"`, default), or the primary stays keyboard/touch-only and every pad opens a new seat (`"none"`).
18
+ */
19
+ claimPrimary?: "first" | "none";
20
+ }
21
+ /** Options for {@link createLocalPlayers}. */
22
+ export interface LocalPlayersOptions extends LocalPlayersConfig {
23
+ /** User id of seat 0 — `ctx.player.userId`. */
24
+ primaryUserId: string;
25
+ /** Input of seat 0 — `ctx.input`; other seats get their own snapshot. */
26
+ primaryInput?: InputSnapshot;
27
+ /** User id for seat `index >= 1` (default `` `${primaryUserId}:p${index + 1}` ``). */
28
+ userIdFor?: (index: number) => string;
29
+ }
30
+ /** Result of {@link LocalPlayers.assign}. */
31
+ export interface LocalPlayerAssignment {
32
+ slot: LocalPlayerSlot;
33
+ /** True when this call opened the seat: spawn its entity through `onNewPlayer`. */
34
+ joined: boolean;
35
+ }
36
+ /** Serializable seat table. */
37
+ export interface LocalPlayersSnapshot {
38
+ maxSlots: number;
39
+ claimPrimary: "first" | "none";
40
+ slots: LocalPlayerSlot[];
41
+ }
42
+ /** Seats for couch co-op: devices hot-join into slots, each slot has its own input. */
43
+ export interface LocalPlayers {
44
+ /** Seat for a device that just produced input: its existing seat, the primary seat, a newly opened seat, or `null` when full. */
45
+ assign(deviceId: string): LocalPlayerAssignment | null;
46
+ /** Free a seat other than the primary; its device may hot-join again. */
47
+ release(slotId: string): boolean;
48
+ slots(): readonly LocalPlayerSlot[];
49
+ slot(slotId: string): LocalPlayerSlot | null;
50
+ slotForDevice(deviceId: string): LocalPlayerSlot | null;
51
+ /** The seat's user id and input, or `null` for an unknown seat. Seat 0 returns `ctx.player`'s id and `ctx.input`. */
52
+ local(slotId: string): {
53
+ userId: string;
54
+ input: InputSnapshot;
55
+ } | null;
56
+ /** Called after every assign that changes the table, release, restore and retune; returns an unsubscribe. */
57
+ subscribe(listener: () => void): () => void;
58
+ config(): Required<LocalPlayersConfig>;
59
+ /** Change seat count or claim policy mid-game; seats above a lowered `maxSlots` stay until released. */
60
+ retune(next: LocalPlayersConfig): void;
61
+ snapshot(): LocalPlayersSnapshot;
62
+ restore(snapshot: LocalPlayersSnapshot): void;
63
+ }
64
+ /**
65
+ * Seat table for local multiplayer on one screen.
66
+ * @capability local-players Couch co-op seats: devices hot-join into slots, each with its own user id and input snapshot.
67
+ */
68
+ export declare function createLocalPlayers(options: LocalPlayersOptions): LocalPlayers;
69
+ /**
70
+ * The seat table of a game context, created on first use with one seat for `ctx.player` and `ctx.input`.
71
+ * The shell retunes it from `defineGame({ localPlayers })` and hot-joins pads into it.
72
+ */
73
+ export declare function localPlayers(ctx: GameContext): LocalPlayers;
@@ -0,0 +1,119 @@
1
+ import { createInputSnapshot } from "./inputSnapshot.js";
2
+ const PRIMARY_SLOT = "slot:0";
3
+ /**
4
+ * Seat table for local multiplayer on one screen.
5
+ * @capability local-players Couch co-op seats: devices hot-join into slots, each with its own user id and input snapshot.
6
+ */
7
+ export function createLocalPlayers(options) {
8
+ const userIdFor = options.userIdFor ?? ((index) => `${options.primaryUserId}:p${index + 1}`);
9
+ const primaryInput = options.primaryInput ?? createInputSnapshot();
10
+ let config = {
11
+ maxSlots: Math.max(1, Math.floor(options.maxSlots)),
12
+ claimPrimary: options.claimPrimary ?? "first",
13
+ };
14
+ let slots = [];
15
+ const inputs = new Map();
16
+ const listeners = new Set();
17
+ const changed = () => {
18
+ for (const listener of [...listeners])
19
+ listener();
20
+ };
21
+ const primarySlot = (deviceId) => Object.freeze({ slotId: PRIMARY_SLOT, index: 0, userId: options.primaryUserId, deviceId });
22
+ const reset = (next) => {
23
+ slots = next.map((slot) => Object.freeze({ ...slot }));
24
+ if (!slots.some((slot) => slot.index === 0))
25
+ slots.unshift(primarySlot(null));
26
+ slots.sort((a, b) => a.index - b.index);
27
+ for (const slotId of [...inputs.keys()]) {
28
+ if (!slots.some((slot) => slot.slotId === slotId))
29
+ inputs.delete(slotId);
30
+ }
31
+ };
32
+ reset([]);
33
+ const inputFor = (slot) => {
34
+ if (slot.index === 0)
35
+ return primaryInput;
36
+ let input = inputs.get(slot.slotId);
37
+ if (input === undefined) {
38
+ input = createInputSnapshot();
39
+ inputs.set(slot.slotId, input);
40
+ }
41
+ return input;
42
+ };
43
+ return {
44
+ assign(deviceId) {
45
+ const existing = slots.find((slot) => slot.deviceId === deviceId);
46
+ if (existing !== undefined)
47
+ return { slot: existing, joined: false };
48
+ if (config.claimPrimary === "first" || config.maxSlots === 1) {
49
+ const primary = slots[0];
50
+ if (primary.deviceId === null) {
51
+ slots[0] = primarySlot(deviceId);
52
+ changed();
53
+ return { slot: slots[0], joined: false };
54
+ }
55
+ if (config.maxSlots === 1)
56
+ return { slot: primary, joined: false };
57
+ }
58
+ if (slots.length >= config.maxSlots)
59
+ return null;
60
+ let index = 1;
61
+ while (slots.some((slot) => slot.index === index))
62
+ index += 1;
63
+ const slot = Object.freeze({ slotId: `slot:${index}`, index, userId: userIdFor(index), deviceId });
64
+ slots = [...slots, slot].sort((a, b) => a.index - b.index);
65
+ changed();
66
+ return { slot, joined: true };
67
+ },
68
+ release(slotId) {
69
+ if (slotId === PRIMARY_SLOT)
70
+ return false;
71
+ const next = slots.filter((slot) => slot.slotId !== slotId);
72
+ if (next.length === slots.length)
73
+ return false;
74
+ slots = next;
75
+ inputs.delete(slotId);
76
+ changed();
77
+ return true;
78
+ },
79
+ slots: () => slots,
80
+ slot: (slotId) => slots.find((slot) => slot.slotId === slotId) ?? null,
81
+ slotForDevice: (deviceId) => slots.find((slot) => slot.deviceId === deviceId) ?? null,
82
+ local(slotId) {
83
+ const slot = slots.find((candidate) => candidate.slotId === slotId);
84
+ return slot === undefined ? null : { userId: slot.userId, input: inputFor(slot) };
85
+ },
86
+ subscribe(listener) {
87
+ listeners.add(listener);
88
+ return () => {
89
+ listeners.delete(listener);
90
+ };
91
+ },
92
+ config: () => config,
93
+ retune(next) {
94
+ config = { maxSlots: Math.max(1, Math.floor(next.maxSlots)), claimPrimary: next.claimPrimary ?? config.claimPrimary };
95
+ changed();
96
+ },
97
+ snapshot() {
98
+ return { ...config, slots: slots.map((slot) => ({ ...slot })) };
99
+ },
100
+ restore(snapshot) {
101
+ config = { maxSlots: Math.max(1, Math.floor(snapshot.maxSlots)), claimPrimary: snapshot.claimPrimary };
102
+ reset(snapshot.slots);
103
+ changed();
104
+ },
105
+ };
106
+ }
107
+ const LOCAL_PLAYERS = new WeakMap();
108
+ /**
109
+ * The seat table of a game context, created on first use with one seat for `ctx.player` and `ctx.input`.
110
+ * The shell retunes it from `defineGame({ localPlayers })` and hot-joins pads into it.
111
+ */
112
+ export function localPlayers(ctx) {
113
+ let players = LOCAL_PLAYERS.get(ctx);
114
+ if (players === undefined) {
115
+ players = createLocalPlayers({ maxSlots: 1, primaryUserId: ctx.player.userId, primaryInput: ctx.input });
116
+ LOCAL_PLAYERS.set(ctx, players);
117
+ }
118
+ return players;
119
+ }
@@ -63,16 +63,25 @@ export type GameRuntimeFeedView = {
63
63
  entries: unknown[];
64
64
  };
65
65
  export type GameRuntimeTransport = {
66
+ /**
67
+ * Join a server. `sessionId` names this client session (one tab, one mounted view) so the host keeps
68
+ * the user a member while any of their sessions is still live.
69
+ */
66
70
  joinServer: (args: {
67
71
  gameId: string;
68
72
  serverId?: string;
69
73
  role?: MultiplayerRole;
74
+ sessionId?: string;
70
75
  }) => Promise<JoinServerOutcome>;
76
+ /** Leave a server. With `sessionId` only that session ends; the user leaves once no session remains. Without it the user leaves outright. */
71
77
  leaveServer: (args: {
72
78
  serverId: string;
79
+ sessionId?: string;
73
80
  }) => Promise<void>;
74
81
  runCommand: (args: RunCommandArgs) => Promise<TransportRunCommandResult>;
75
82
  };
83
+ /** Fresh id for one client session's join/leave pair (see {@link GameRuntimeTransport.joinServer}). */
84
+ export declare function createTransportSessionId(): string;
76
85
  export type FeedUnsubscribe = () => void;
77
86
  export type GameRuntimeFeeds = {
78
87
  subscribeServer: (serverId: string, onChange: (view: GameRuntimeServerView | null) => void) => FeedUnsubscribe;
@@ -1 +1,7 @@
1
- export {};
1
+ /** Fresh id for one client session's join/leave pair (see {@link GameRuntimeTransport.joinServer}). */
2
+ export function createTransportSessionId() {
3
+ const cryptoApi = globalThis.crypto;
4
+ if (cryptoApi?.randomUUID)
5
+ return cryptoApi.randomUUID();
6
+ return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
7
+ }
@@ -1,4 +1,4 @@
1
- import type { DecisionAction, DecisionGraphRuntime } from "../ai/decisionGraph.js";
1
+ import type { Blackboard, DecisionAbort, DecisionAction, DecisionGraphRuntime } from "../ai/decisionGraph.js";
2
2
  import { type InterestGateState } from "../ai/interestScheduler.js";
3
3
  import { type PathFollowState, type PathProgress, type Waypoint } from "../nav/pathFollow.js";
4
4
  import type { GameContext } from "../runtime/gameContext.js";
@@ -13,10 +13,15 @@ export interface BehaviorActionContext {
13
13
  }
14
14
  /** Callback used by a registered decision graph behavior action. */
15
15
  export type BehaviorAction = DecisionAction<BehaviorActionContext>;
16
+ /** Optional hooks for a registered behavior action set. */
17
+ export interface BehaviorActionOptions {
18
+ /** Called when an action running last think is pre-empted by another branch, to stop movement or release claims. */
19
+ onAbort?: DecisionAbort<BehaviorActionContext>;
20
+ }
16
21
  /** Register the named actions used by decision graph behavior descriptors.
17
22
  * @capability behavior-actions register named callbacks for decision graph behavior descriptors
18
23
  */
19
- export declare function registerBehaviorActions(id: string, actions: Record<string, BehaviorAction>): () => void;
24
+ export declare function registerBehaviorActions(id: string, actions: Record<string, BehaviorAction>, options?: BehaviorActionOptions): () => void;
20
25
  /**
21
26
  * Catch-up policy applied when a paused instance resumes. `freeze` (default, deterministic) discards
22
27
  * the time spent paused; `advance` silently fast-forwards the instance by the paused duration without
@@ -36,6 +41,9 @@ export type BehaviorSnapshot = {
36
41
  readonly kind: "decisionGraph";
37
42
  readonly graph: ReturnType<DecisionGraphRuntime["snapshot"]>;
38
43
  readonly gate: InterestGateState;
44
+ readonly blackboard?: Blackboard;
45
+ /** Seconds accrued since the last think. */
46
+ readonly pending?: number;
39
47
  };
40
48
  /** Inspection readout for editor/debug tooling, from {@link BehaviorControl.inspect}/{@link BehaviorControl.list}. */
41
49
  export interface BehaviorInspection {
@@ -72,6 +80,8 @@ export interface BehaviorControl {
72
80
  inspect(id: string): BehaviorInspection | null;
73
81
  /** Inspect every live instance (bounded by spawned behavior entities). */
74
82
  list(): BehaviorInspection[];
83
+ /** The live, persistent blackboard of a decision graph instance for perception and game systems to write facts into, or `null`. */
84
+ blackboard(id: string): Blackboard | null;
75
85
  }
76
86
  /**
77
87
  * Advance every spawned entity carrying a `patrol` or `wander` {@link BehaviorDescriptor} one tick — the
@@ -11,14 +11,17 @@ const behaviorActions = new Map();
11
11
  /** Register the named actions used by decision graph behavior descriptors.
12
12
  * @capability behavior-actions register named callbacks for decision graph behavior descriptors
13
13
  */
14
- export function registerBehaviorActions(id, actions) {
15
- behaviorActions.set(id, actions);
14
+ export function registerBehaviorActions(id, actions, options = {}) {
15
+ const entry = { actions, options };
16
+ behaviorActions.set(id, entry);
16
17
  return () => {
17
- if (behaviorActions.get(id) === actions)
18
+ if (behaviorActions.get(id) === entry)
18
19
  behaviorActions.delete(id);
19
20
  };
20
21
  }
21
- const DECISION_GRAPH_CADENCE = { wakeRadius: Number.POSITIVE_INFINITY, activeInterval: 0 };
22
+ function thinkCadence(behavior) {
23
+ return { wakeRadius: Number.POSITIVE_INFINITY, activeInterval: Math.max(0, behavior.thinkInterval ?? 0) };
24
+ }
22
25
  function patrolOf(entity) {
23
26
  return entity.behaviors.find((b) => b.kind === "patrol") ?? null;
24
27
  }
@@ -66,14 +69,21 @@ const runtimeOf = perContext((ctx) => {
66
69
  }
67
70
  const decisionGraph = decisionGraphOf(entity);
68
71
  if (decisionGraph !== null) {
69
- const actions = behaviorActions.get(decisionGraph.actions);
70
- if (actions === undefined)
72
+ const registered = behaviorActions.get(decisionGraph.actions);
73
+ if (registered === undefined)
71
74
  continue;
75
+ const cadence = thinkCadence(decisionGraph);
72
76
  nav.set(entity.id, {
73
77
  kind: "decisionGraph",
74
78
  behavior: decisionGraph,
75
- runtime: createDecisionGraphRuntime(decisionGraph.graph, actions),
76
- gate: createInterestGateState(DECISION_GRAPH_CADENCE, ctx.rng()),
79
+ runtime: createDecisionGraphRuntime(decisionGraph.graph, registered.actions, {
80
+ rng: () => ctx.rng(),
81
+ ...(registered.options.onAbort === undefined ? {} : { onAbort: registered.options.onAbort }),
82
+ }),
83
+ gate: createInterestGateState(cadence, ctx.rng()),
84
+ cadence,
85
+ blackboard: { ...(decisionGraph.blackboard ?? {}) },
86
+ pending: 0,
77
87
  status: "active",
78
88
  reason: null,
79
89
  pausedElapsed: 0,
@@ -174,13 +184,13 @@ export function advanceBehaviors(ctx, dt) {
174
184
  const entity = ctx.scene.entity.get(id);
175
185
  if (entity === null)
176
186
  continue;
177
- const step = advanceInterestGate(entry.gate, DECISION_GRAPH_CADENCE, dt, { proximity: 0 });
187
+ entry.pending += dt;
188
+ const step = advanceInterestGate(entry.gate, entry.cadence, dt, { proximity: 0 });
178
189
  if (!step.active)
179
190
  continue;
180
- const blackboard = {};
181
- const status = entry.runtime.tick({ ctx, entityId: id, dt }, blackboard, dt);
182
- if (status === "done" || status === "failed")
183
- entry.runtime.restore({ runningPath: null });
191
+ const elapsed = entry.pending;
192
+ entry.pending = 0;
193
+ entry.runtime.tick({ ctx, entityId: id, dt: elapsed }, entry.blackboard, elapsed);
184
194
  }
185
195
  }
186
196
  }
@@ -252,7 +262,7 @@ export function behaviorControl(ctx) {
252
262
  return { kind: "patrol", state: { ...entry.state } };
253
263
  if (entry.kind === "wander")
254
264
  return { kind: "wander", origin: [...entry.origin], target: entry.target === null ? null : [...entry.target] };
255
- return { kind: "decisionGraph", graph: entry.runtime.snapshot(), gate: { ...entry.gate } };
265
+ return { kind: "decisionGraph", graph: entry.runtime.snapshot(), gate: { ...entry.gate }, blackboard: { ...entry.blackboard }, pending: entry.pending };
256
266
  },
257
267
  restore: (id, snapshot) => {
258
268
  const entry = nav.get(id);
@@ -265,6 +275,8 @@ export function behaviorControl(ctx) {
265
275
  if (entry.kind === "decisionGraph" && snapshot.kind === "decisionGraph") {
266
276
  entry.runtime.restore(snapshot.graph);
267
277
  entry.gate = { ...snapshot.gate };
278
+ entry.blackboard = { ...(snapshot.blackboard ?? entry.behavior.blackboard ?? {}) };
279
+ entry.pending = snapshot.pending ?? 0;
268
280
  return true;
269
281
  }
270
282
  if (entry.kind === "wander" && snapshot.kind === "wander") {
@@ -286,5 +298,9 @@ export function behaviorControl(ctx) {
286
298
  out.push({ id, kind: entry.kind, status: entry.status, reason: entry.reason });
287
299
  return out;
288
300
  },
301
+ blackboard: (id) => {
302
+ const entry = nav.get(id);
303
+ return entry?.kind === "decisionGraph" ? entry.blackboard : null;
304
+ },
289
305
  };
290
306
  }
@@ -1,6 +1,6 @@
1
1
  import { type ProximityPrompt } from "../interaction/proximityPrompt.js";
2
2
  import type { PathProgress, Waypoint } from "../nav/pathFollow.js";
3
- import type { DecisionGraph } from "../ai/decisionGraph.js";
3
+ import type { Blackboard, DecisionGraph } from "../ai/decisionGraph.js";
4
4
  export interface WanderBehavior {
5
5
  kind: "wander";
6
6
  radius: number;
@@ -33,6 +33,10 @@ export interface DecisionGraphBehavior {
33
33
  kind: "decisionGraph";
34
34
  graph: DecisionGraph;
35
35
  actions: string;
36
+ /** Initial facts for this entity's persistent blackboard; systems write more through `behaviorControl(ctx).blackboard(id)`. */
37
+ blackboard?: Blackboard;
38
+ /** Seconds between graph ticks (`0` = every frame). Siblings are staggered; actions receive the elapsed time. */
39
+ thinkInterval?: number;
36
40
  }
37
41
  export type BehaviorDescriptor = WanderBehavior | PatrolBehavior | PromptableBehavior | PlayerBehavior | DecisionGraphBehavior;
38
42
  export declare function wander({ radius }: {
@@ -5,6 +5,7 @@
5
5
  * party ship a new authorable "studio" (pole line, water, bookcase) — schema + resolver here, a
6
6
  * matching renderer in `shell` — without editing editor or engine files. Scatter is the proof adopter.
7
7
  */
8
+ import type { WorldSolid } from "../world/worldSolids.js";
8
9
  /** Which document collection a scene kind lives in. Drives placement + which `meta` bag holds params. */
9
10
  export type SceneKindTarget = "path" | "marker" | "volume";
10
11
  /** A closed-polygon area vs an open polyline, for path-target kinds. Ignored for marker/volume kinds. */
@@ -212,6 +213,12 @@ export interface SceneKindDefinition<TResolved = unknown> {
212
213
  * marker/volume/path; `params` is `parseParams(schema, object.meta)`.
213
214
  */
214
215
  resolve?: (object: SceneKindObject, params: ParsedParams, context: SceneKindResolveContext) => TResolved;
216
+ /**
217
+ * World-space collision for a resolved object. Omitted, the kind is render-only. `syncAuthoredSolids`
218
+ * (`world/authoredSolids`) writes these into `ctx.world.solids`, so movement, NPCs, physics
219
+ * backends and nav grids all collide with what the studio draws.
220
+ */
221
+ solids?: (resolved: TResolved, object: SceneKindObject, params: ParsedParams, context: SceneKindResolveContext) => readonly WorldSolid[];
215
222
  /** Optional one-line readout under the inspector fields (estimate, dimensions) — owned by the studio. */
216
223
  note?: (object: SceneKindObject, params: ParsedParams) => string;
217
224
  /**
@@ -258,6 +265,8 @@ export interface SceneKindObject {
258
265
  export interface SceneKindResolveContext {
259
266
  sampleHeight?: (x: number, z: number) => number;
260
267
  sampleNormal?: (x: number, z: number) => readonly [number, number, number];
268
+ /** Every studio object in the same document, for kinds that read siblings (a `city` reads its `cityzone` volumes). */
269
+ objects?: readonly SceneKindObject[];
261
270
  }
262
271
  /**
263
272
  * Register a scene kind — the plug-in point for a new parametric studio. Idempotent per `kind` (last
@@ -64,5 +64,9 @@ export declare class VehicleSeats {
64
64
  driverOf(vehicleId: string): string | null;
65
65
  get mounts(): MountController;
66
66
  }
67
- /** Builds a {@link VehicleSeats}, optionally over an existing `MountController` to share its occupancy. */
67
+ /**
68
+ * Builds a {@link VehicleSeats}, optionally over an existing `MountController` to share its occupancy.
69
+ *
70
+ * @capability vehicle-seats enter, exit and swap seats in a car or mount (GTA-style boarding) with driver and passenger slots
71
+ */
68
72
  export declare function createVehicleSeats(controller?: MountController): VehicleSeats;
@@ -53,7 +53,11 @@ export class VehicleSeats {
53
53
  return this.controller;
54
54
  }
55
55
  }
56
- /** Builds a {@link VehicleSeats}, optionally over an existing `MountController` to share its occupancy. */
56
+ /**
57
+ * Builds a {@link VehicleSeats}, optionally over an existing `MountController` to share its occupancy.
58
+ *
59
+ * @capability vehicle-seats enter, exit and swap seats in a car or mount (GTA-style boarding) with driver and passenger slots
60
+ */
57
61
  export function createVehicleSeats(controller) {
58
62
  return new VehicleSeats(controller);
59
63
  }
@@ -14,9 +14,15 @@ export interface ConcealmentSample {
14
14
  concealed: boolean;
15
15
  dwellSeconds: number;
16
16
  }
17
+ /** Plain JSON state of a {@link ConcealmentSensor}: seconds each target has stayed concealed. */
18
+ export interface ConcealmentSensorState {
19
+ dwell: Record<string, number>;
20
+ }
17
21
  export interface ConcealmentSensor {
18
22
  tick(targets: readonly ConcealmentTarget[], dt: number): ConcealmentSample[];
19
23
  reset(id?: string): void;
24
+ snapshot(): ConcealmentSensorState;
25
+ restore(next: ConcealmentSensorState): void;
20
26
  }
21
27
  /** @internal */
22
28
  export declare function createConcealmentSensor(config?: {
@@ -62,5 +62,13 @@ export function createConcealmentSensor(config) {
62
62
  else
63
63
  dwell.delete(id);
64
64
  },
65
+ snapshot() {
66
+ return { dwell: Object.fromEntries(dwell) };
67
+ },
68
+ restore(next) {
69
+ dwell.clear();
70
+ for (const [id, seconds] of Object.entries(next.dwell))
71
+ dwell.set(id, seconds);
72
+ },
65
73
  };
66
74
  }