@jgengine/core 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +114 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +85 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +12 -7
  90. package/dist/runtime/commandRunner.js +7 -5
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +20 -4
  94. package/dist/runtime/gameRuntime.js +17 -4
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +32 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +94 -19
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
@@ -2,8 +2,8 @@ import type { BackdropConfig, LightingConfig } from "../game/playableGame.js";
2
2
  import type { SkyEnvironmentConfig } from "../world/features.js";
3
3
  import type { PostProcessingConfig } from "./postProcessing.js";
4
4
  /**
5
- * Named default-look preset composing the existing lighting/sky/fog/post knobs into one field.
6
- * `"cinematic"` (the default when unset) draws a scene lit like a shipped game — a real day sky
5
+ * Named look preset composing the existing lighting/sky/fog/post knobs into one field.
6
+ * `"neutral"` (the default when unset) keeps presentation unstyled; `"cinematic"` draws a scene lit like a shipped game — a real day sky
7
7
  * with a view-following shadow-casting sun + hemisphere fill, a network-free image-based-lighting
8
8
  * environment so PBR surfaces catch soft reflections, and a tuned tone-map/bloom/AO/vignette post
9
9
  * stack. `"flat"` opts out of the sky/IBL/post rig to the bare ambient+directional default (pre-#773).
@@ -11,7 +11,7 @@ import type { PostProcessingConfig } from "./postProcessing.js";
11
11
  * detail so un-modeled boxes/capsules stop reading as flat plastic — apply under both presets.
12
12
  * @capability default-look one field that lights a scene like a shipped game (opt out with "flat")
13
13
  */
14
- export type LookPreset = "cinematic" | "flat";
14
+ export type LookPreset = "neutral" | "photoreal" | "toon" | "cinematic" | "flat";
15
15
  /** Explicit graphics knobs a game may set; the preset only fills the ones left undefined. */
16
16
  export interface GameLookInput {
17
17
  look?: LookPreset;
@@ -31,9 +31,15 @@ export interface ResolvedGameLook {
31
31
  export declare const CINEMATIC_SKY: SkyEnvironmentConfig;
32
32
  /** Tuned tone-map + bloom + gentle SSAO + vignette/grade stack — the shipped-game post look. */
33
33
  export declare const CINEMATIC_POST_PROCESSING: PostProcessingConfig;
34
+ /** Unstyled baseline: a stable linear presentation with no genre-specific grading. */
35
+ export declare const NEUTRAL_POST_PROCESSING: PostProcessingConfig;
36
+ /** High-contrast physically based presentation for authored HDR environments. */
37
+ export declare const PHOTOREAL_POST_PROCESSING: PostProcessingConfig;
38
+ /** Clean graphic presentation; toon materials remain game-owned while this sets the grade. */
39
+ export declare const TOON_POST_PROCESSING: PostProcessingConfig;
34
40
  /**
35
- * Expand a game's `look` into concrete lighting/backdrop/post. The default is `"cinematic"`, so a
36
- * scene reads lit-like-a-game out of the box; `"flat"` passes the explicit knobs through untouched.
41
+ * Expand a game's `look` into concrete lighting/backdrop/post. The default is `"neutral"`;
42
+ * `"flat"` passes the explicit knobs through untouched.
37
43
  * Anything the game authored wins — the preset only fills unset knobs, and it never adds a sky when
38
44
  * the world already owns one (so the sky's tuned sun/hemisphere serve as the lighting rig).
39
45
  * @capability resolve-game-look expand a look preset into concrete lighting/backdrop/post knobs
@@ -7,23 +7,48 @@ export const CINEMATIC_POST_PROCESSING = {
7
7
  ao: { intensity: 1.4 },
8
8
  grade: {},
9
9
  };
10
+ /** Unstyled baseline: a stable linear presentation with no genre-specific grading. */
11
+ export const NEUTRAL_POST_PROCESSING = {
12
+ toneMapping: "agx",
13
+ bloom: false,
14
+ ao: false,
15
+ grade: false,
16
+ };
17
+ /** High-contrast physically based presentation for authored HDR environments. */
18
+ export const PHOTOREAL_POST_PROCESSING = {
19
+ toneMapping: "aces",
20
+ bloom: { strength: 0.28, radius: 0.58, threshold: 0.9 },
21
+ ao: { intensity: 1.25, radius: 1.6 },
22
+ grade: { saturation: 1.04, vignette: 0.08, grain: 0.004 },
23
+ };
24
+ /** Clean graphic presentation; toon materials remain game-owned while this sets the grade. */
25
+ export const TOON_POST_PROCESSING = {
26
+ toneMapping: "linear",
27
+ bloom: { strength: 0.08, radius: 0.35, threshold: 1.1 },
28
+ ao: { intensity: 0.8, radius: 1.2 },
29
+ grade: { saturation: 1.18, gamma: 1, vignette: 0, grain: 0 },
30
+ };
10
31
  /**
11
- * Expand a game's `look` into concrete lighting/backdrop/post. The default is `"cinematic"`, so a
12
- * scene reads lit-like-a-game out of the box; `"flat"` passes the explicit knobs through untouched.
32
+ * Expand a game's `look` into concrete lighting/backdrop/post. The default is `"neutral"`;
33
+ * `"flat"` passes the explicit knobs through untouched.
13
34
  * Anything the game authored wins — the preset only fills unset knobs, and it never adds a sky when
14
35
  * the world already owns one (so the sky's tuned sun/hemisphere serve as the lighting rig).
15
36
  * @capability resolve-game-look expand a look preset into concrete lighting/backdrop/post knobs
16
37
  */
17
38
  export function resolveGameLook(input) {
18
- const look = input.look ?? "cinematic";
39
+ const look = input.look ?? "neutral";
19
40
  if (look === "flat") {
20
41
  return { lighting: input.lighting, backdrop: input.backdrop, postProcessing: input.postProcessing };
21
42
  }
43
+ if (look === "neutral") {
44
+ return { lighting: input.lighting, backdrop: input.backdrop, postProcessing: input.postProcessing ?? NEUTRAL_POST_PROCESSING };
45
+ }
22
46
  const sky = input.backdrop?.sky ?? (input.hasWorldSky === true ? undefined : CINEMATIC_SKY);
23
47
  const backdrop = sky === input.backdrop?.sky ? input.backdrop : { ...input.backdrop, sky };
24
48
  return {
25
49
  lighting: input.lighting,
26
50
  backdrop,
27
- postProcessing: input.postProcessing ?? CINEMATIC_POST_PROCESSING,
51
+ postProcessing: input.postProcessing ??
52
+ (look === "toon" ? TOON_POST_PROCESSING : look === "photoreal" ? PHOTOREAL_POST_PROCESSING : CINEMATIC_POST_PROCESSING),
28
53
  };
29
54
  }
@@ -6,7 +6,11 @@ export interface BloomConfig {
6
6
  strength?: number;
7
7
  /** Glow spread radius, 0..1. Default 0.55. */
8
8
  radius?: number;
9
- /** Luminance above which a pixel blooms, 0..1. Default 0.85. */
9
+ /**
10
+ * Luminance above which a pixel blooms. Default 0.85. Measured on raw HDR
11
+ * before the tone-map/exposure stage, so lowering `exposure` does not change
12
+ * which pixels bloom — push past 1.0 to select only emissives.
13
+ */
10
14
  threshold?: number;
11
15
  }
12
16
  /** Ground-truth ambient occlusion stage — darkens contact creases and cavities for depth. */
@@ -22,6 +26,11 @@ export interface AoConfig {
22
26
  }
23
27
  /** Final colour-grade stage: lift/gain/gamma, saturation, vignette, film grain — applied in display space after tone mapping. */
24
28
  export interface GradeConfig {
29
+ /** Optional 2D strip LUT URL. The strip contains size square tiles, one tile per blue slice. */
30
+ lut?: {
31
+ url: string;
32
+ size?: number;
33
+ };
25
34
  /** Additive shadow tint (RGB, ~0..0.1). Default cool [0.012, 0.010, 0.018]. */
26
35
  lift?: readonly [number, number, number];
27
36
  /** Multiplicative highlight tint (RGB, ~1). Default warm [1.05, 1.02, 0.98]. */
@@ -0,0 +1,38 @@
1
+ import type { SpriteAtlas } from "../assets/spriteAtlas.js";
2
+ /** Serializable playback position for a named sprite animation. */
3
+ export interface SpriteClipState {
4
+ animation: string;
5
+ frameIndex: number;
6
+ elapsed: number;
7
+ done: boolean;
8
+ }
9
+ /** Create a renderer-independent sprite animation player.
10
+ * @capability sprite-2d animate a sprite atlas clip from headless or rendered game code
11
+ */
12
+ export declare function createSpriteClipPlayer(atlas: SpriteAtlas): {
13
+ play(name: string): void;
14
+ advance(dt: number): void;
15
+ frame: () => {
16
+ x: number;
17
+ y: number;
18
+ w: number;
19
+ h: number;
20
+ pivot?: [number, number];
21
+ } | undefined;
22
+ snapshot: () => {
23
+ animation: string;
24
+ frameIndex: number;
25
+ elapsed: number;
26
+ done: boolean;
27
+ };
28
+ restore(next: SpriteClipState): void;
29
+ retune(next: {
30
+ speed: number;
31
+ }): void;
32
+ };
33
+ /** Canonical layers used by 2D presentations. */
34
+ export declare const SortingLayers: readonly ["background", "world", "actors", "effects", "foreground", "ui"];
35
+ /** Resolve a layer name and local offset into a stable render order.
36
+ * @capability sprite-2d order sprites by named presentation layers
37
+ */
38
+ export declare function sortingOrder(layers: readonly string[], layer: string, offset?: number): number;
@@ -0,0 +1,40 @@
1
+ /** Create a renderer-independent sprite animation player.
2
+ * @capability sprite-2d animate a sprite atlas clip from headless or rendered game code
3
+ */
4
+ export function createSpriteClipPlayer(atlas) {
5
+ let state = { animation: Object.keys(atlas.animations)[0] ?? "", frameIndex: 0, elapsed: 0, done: false };
6
+ let speed = 1;
7
+ const current = () => { const animation = atlas.animations[state.animation]; if (!animation)
8
+ return undefined; return atlas.frames[animation.frames[state.frameIndex]]; };
9
+ return {
10
+ play(name) { if (!atlas.animations[name])
11
+ throw new Error(`Unknown sprite animation: ${name}.`); state = { animation: name, frameIndex: 0, elapsed: 0, done: false }; },
12
+ advance(dt) { if (!Number.isFinite(dt) || dt < 0)
13
+ throw new Error("dt must be non-negative."); const animation = atlas.animations[state.animation]; if (!animation || state.done)
14
+ return; state.elapsed += dt * speed; const duration = 1 / animation.fps; while (state.elapsed >= duration && !state.done) {
15
+ state.elapsed -= duration;
16
+ if (state.frameIndex + 1 < animation.frames.length)
17
+ state.frameIndex += 1;
18
+ else if (animation.loop === false) {
19
+ state.frameIndex = animation.frames.length - 1;
20
+ state.done = true;
21
+ }
22
+ else
23
+ state.frameIndex = 0;
24
+ } },
25
+ frame: current,
26
+ snapshot: () => ({ ...state }),
27
+ restore(next) { if (!atlas.animations[next.animation])
28
+ throw new Error(`Unknown sprite animation: ${next.animation}.`); state = { ...next }; },
29
+ retune(next) { if (!Number.isFinite(next.speed) || next.speed < 0)
30
+ throw new Error("speed must be non-negative."); speed = next.speed; },
31
+ };
32
+ }
33
+ /** Canonical layers used by 2D presentations. */
34
+ export const SortingLayers = ["background", "world", "actors", "effects", "foreground", "ui"];
35
+ /** Resolve a layer name and local offset into a stable render order.
36
+ * @capability sprite-2d order sprites by named presentation layers
37
+ */
38
+ export function sortingOrder(layers, layer, offset = 0) { const index = layers.indexOf(layer); if (index < 0)
39
+ throw new Error(`Unknown sorting layer: ${layer}.`); if (!Number.isFinite(offset))
40
+ throw new Error("offset must be finite."); return index + offset; }
@@ -122,9 +122,28 @@ export declare function offline(): MultiplayerAdapterConfig;
122
122
  export declare function adapterOf(multiplayer: unknown): MultiplayerAdapterConfig | null;
123
123
  export declare function multiplayerAdapterKind(multiplayer: unknown): string | null;
124
124
  export type ServersPoolConfig = {
125
+ minPlayersToStart?: number;
126
+ adapter: MultiplayerAdapterConfig;
127
+ } & ({
128
+ topology: "shared";
129
+ maxServers?: number;
130
+ slotsPerServer?: number;
131
+ } | {
132
+ topology?: "rooms";
133
+ maxServers: number;
134
+ slotsPerServer: number;
135
+ });
136
+ /** Resolve the shared-world pool to one unbounded world; room pools keep explicit capacities. */
137
+ export declare function servers(config: ServersPoolConfig): {
138
+ minPlayersToStart?: number;
139
+ adapter: MultiplayerAdapterConfig;
140
+ topology: "rooms" | "shared";
125
141
  maxServers: number;
126
142
  slotsPerServer: number;
143
+ } | {
127
144
  minPlayersToStart?: number;
128
145
  adapter: MultiplayerAdapterConfig;
146
+ topology: "rooms" | "shared";
147
+ maxServers: number;
148
+ slotsPerServer: number;
129
149
  };
130
- export declare function servers(config: ServersPoolConfig): ServersPoolConfig;
@@ -105,6 +105,13 @@ export function adapterOf(multiplayer) {
105
105
  export function multiplayerAdapterKind(multiplayer) {
106
106
  return adapterOf(multiplayer)?.kind ?? null;
107
107
  }
108
+ /** Resolve the shared-world pool to one unbounded world; room pools keep explicit capacities. */
108
109
  export function servers(config) {
109
- return config;
110
+ const shared = config.topology === "shared";
111
+ const maxServers = shared ? 1 : config.maxServers;
112
+ const slotsPerServer = shared ? Number.MAX_SAFE_INTEGER : config.slotsPerServer;
113
+ if (!Number.isSafeInteger(maxServers) || maxServers < 1 || !Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) {
114
+ throw new RangeError("Server pool capacities must be positive safe integers");
115
+ }
116
+ return { ...config, topology: config.topology ?? "rooms", maxServers, slotsPerServer };
110
117
  }
@@ -0,0 +1,15 @@
1
+ /** Validated integer quantity or a stable input rejection. */
2
+ export type QuantityResult = {
3
+ ok: true;
4
+ quantity: number;
5
+ } | {
6
+ ok: false;
7
+ reason: "invalid-quantity";
8
+ };
9
+ /** Read an untrusted whole-item quantity without coercion, truncation, or clamping.
10
+ * @capability command-quantity validate bounded whole-item counts at command boundaries
11
+ */
12
+ export declare function readQuantity(value: unknown, options?: {
13
+ min?: number;
14
+ max?: number;
15
+ }): QuantityResult;
@@ -0,0 +1,13 @@
1
+ /** Read an untrusted whole-item quantity without coercion, truncation, or clamping.
2
+ * @capability command-quantity validate bounded whole-item counts at command boundaries
3
+ */
4
+ export function readQuantity(value, options = {}) {
5
+ const min = options.min ?? 1;
6
+ const max = options.max ?? Number.MAX_SAFE_INTEGER;
7
+ if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min < 0 || max < min) {
8
+ throw new RangeError("Quantity bounds must be nonnegative safe integers in order");
9
+ }
10
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max
11
+ ? { ok: true, quantity: value }
12
+ : { ok: false, reason: "invalid-quantity" };
13
+ }
@@ -12,23 +12,28 @@ export type CommandScope = {
12
12
  /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */
13
13
  chunkKeys?: readonly string[];
14
14
  };
15
+ /** Declarative read scope; actor is resolved before host hydration. */
16
+ export type CommandScopeDefinition = Omit<CommandScope, "players"> & {
17
+ players?: readonly string[] | "actor";
18
+ };
15
19
  export type CommandDef<TInput = unknown> = {
16
20
  /**
17
21
  * What this command reads and writes, derived from its own input. A host that hydrates through a
18
22
  * scope loads only this slice instead of the whole world, and refuses the command if `apply` then
19
23
  * dirties a player or chunk the scope did not name — writing an unhydrated chunk would overwrite
20
- * the stored one with a blank. Omit it to keep the whole-world default.
24
+ * the stored one with a blank. Omit it to load only the actor and no chunks; return `{}` to request the whole world.
21
25
  */
22
- scope?: (input: TInput, actorUserId: string) => CommandScope;
23
- validate: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => CommandValidationError | null;
24
- apply: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => import("./snapshot.js").GameRuntimeSnapshot;
26
+ scope?: (input: TInput, actorUserId: string) => CommandScopeDefinition;
27
+ /** `nowMs` is the host wall clock, in ms — read it rather than a `now` the client put in `input`. */
28
+ validate: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string, nowMs: number) => CommandValidationError | null;
29
+ apply: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string, nowMs: number) => import("./snapshot.js").GameRuntimeSnapshot;
25
30
  };
26
31
  /**
27
- * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
32
+ * The resolved scope, defaulting to the actor and no chunks.
28
33
  * Evaluate it before hydration: the actor and the command's own input are both known by then.
29
34
  * @internal
30
35
  */
31
- export declare function resolveCommandScope<TInput>(commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string): CommandScope | undefined;
36
+ export declare function resolveCommandScope<TInput>(commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string): CommandScope;
32
37
  /**
33
38
  * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
34
39
  * {@link runCommand} whatever the command did, and an unhydrated player is dropped from the persist.
@@ -49,4 +54,4 @@ export type RunCommandResult = {
49
54
  reason: string;
50
55
  };
51
56
  /** @internal */
52
- export declare function runCommand<TInput>(snapshot: import("./snapshot.js").GameRuntimeSnapshot, commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string): RunCommandResult;
57
+ export declare function runCommand<TInput>(snapshot: import("./snapshot.js").GameRuntimeSnapshot, commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string, nowMs?: number): RunCommandResult;
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
2
+ * The resolved scope, defaulting to the actor and no chunks.
3
3
  * Evaluate it before hydration: the actor and the command's own input are both known by then.
4
4
  * @internal
5
5
  */
6
6
  export function resolveCommandScope(commands, commandName, input, actorUserId) {
7
- return commands[commandName]?.scope?.(input, actorUserId);
7
+ const scope = commands[commandName]?.scope?.(input, actorUserId) ?? { players: "actor", chunkKeys: [] };
8
+ const { players, ...rest } = scope;
9
+ return players === undefined ? rest : { ...rest, players: players === "actor" ? [actorUserId] : players };
8
10
  }
9
11
  /**
10
12
  * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
@@ -35,16 +37,16 @@ export function commandScopeEscape(scope, dirty, commandName) {
35
37
  return `Command "${commandName}" wrote outside its declared scope: ${parts.join("; ")}`;
36
38
  }
37
39
  /** @internal */
38
- export function runCommand(snapshot, commands, commandName, input, actorUserId) {
40
+ export function runCommand(snapshot, commands, commandName, input, actorUserId, nowMs = Date.now()) {
39
41
  const command = commands[commandName];
40
42
  if (!command) {
41
43
  return { ok: false, reason: `Unknown command: ${commandName}` };
42
44
  }
43
- const validationError = command.validate(snapshot, input, actorUserId);
45
+ const validationError = command.validate(snapshot, input, actorUserId, nowMs);
44
46
  if (validationError) {
45
47
  return { ok: false, reason: validationError.reason };
46
48
  }
47
- const next = command.apply(snapshot, input, actorUserId);
49
+ const next = command.apply(snapshot, input, actorUserId, nowMs);
48
50
  return {
49
51
  ok: true,
50
52
  snapshot: {
@@ -19,8 +19,10 @@ import { createRuntimeSave } from "./runtimeSave.js";
19
19
  import { isOffline } from "./adapter.js";
20
20
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
21
21
  import { createSimClock } from "../time/simClock.js";
22
+ import { createSimContext } from "./simContext.js";
22
23
  import { seededRng } from "../random/rng.js";
23
24
  import { createCameraDirector } from "./cameraDirector.js";
25
+ import { createParticleDirector } from "../vfx/particleDirector.js";
24
26
  import { createInputSnapshot } from "./inputSnapshot.js";
25
27
  import { baselineDescriptors } from "./descriptors/baseline.js";
26
28
  import { featureDescriptors } from "./descriptors/features.js";
@@ -220,6 +222,7 @@ export function createGameContext(options) {
220
222
  const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
221
223
  const { pile, loop, raceState, cardPiles, turnLoops } = createContextRegistries(signal.notify);
222
224
  const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
225
+ const particles = createParticleDirector();
223
226
  const input = createInputSnapshot();
224
227
  // --- Descriptor install (single feature enable path) ---
225
228
  const featureDeps = {
@@ -289,7 +292,7 @@ export function createGameContext(options) {
289
292
  ...featureReplicateModules,
290
293
  ];
291
294
  const replication = options.replication;
292
- const projectsViewers = policyProjectsViewers(replication);
295
+ const projectsViewers = policyProjectsViewers(replication) || snapshotModules.some((module) => module.priority !== undefined);
293
296
  const aoiRadius = replication?.aoiRadius;
294
297
  const typedProject = (project) => (data, viewer, world) => project(data, viewer, world);
295
298
  const projectorFor = (key) => {
@@ -405,6 +408,7 @@ export function createGameContext(options) {
405
408
  groundHeightAt: ground.sampleHeight,
406
409
  },
407
410
  game: {
411
+ territory: options.territory,
408
412
  commands: {
409
413
  define: commandRegistry.define,
410
414
  has: commandRegistry.has,
@@ -496,7 +500,9 @@ export function createGameContext(options) {
496
500
  weapon,
497
501
  },
498
502
  time,
503
+ sim: createSimContext({ config: definition.simulation, entities }),
499
504
  camera,
505
+ particles,
500
506
  input,
501
507
  subscribe: signal.subscribe,
502
508
  version: signal.version,
@@ -510,6 +516,10 @@ export function createGameContext(options) {
510
516
  replicatesPerViewer: () => projectsViewers,
511
517
  };
512
518
  ctxRef = ctx;
519
+ const physicsBackend = definition.physics?.backend;
520
+ if (physicsBackend !== undefined) {
521
+ ctx.sim.addStage({ id: "physics", phase: "afterMovement", run: (dt) => physicsBackend.step(dt) });
522
+ }
513
523
  const saveOptions = resolveSaveOptions(definition, options);
514
524
  if (saveOptions !== undefined) {
515
525
  const saveTarget = {
@@ -1,3 +1,5 @@
1
+ import type { Territory } from "../world/territory.js";
2
+ import type { CurrencyDefinition } from "../economy/currency.js";
1
3
  import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
2
4
  import type { EffectInput, EffectResult, EffectVia, ReceiveMap, SingleTargetEffectInput } from "../combat/effects.js";
3
5
  import type { ProjectileSystem } from "../combat/projectiles.js";
@@ -52,9 +54,11 @@ import type { SnapshotModule, SnapshotViewer, WorldSnapshot } from "./worldSnaps
52
54
  import type { ReplicationPolicy } from "./worldProjection.js";
53
55
  import type { RuntimeSave, RuntimeSaveOptions } from "./runtimeSave.js";
54
56
  import type { SimClock } from "../time/simClock.js";
57
+ import type { SimContext } from "./simContext.js";
55
58
  import type { TurnLoop, TurnLoopConfig } from "../turn/turnLoop.js";
56
59
  import type { RaceState, RaceStateConfig } from "../game/race.js";
57
60
  import type { CameraDirector } from "./cameraDirector.js";
61
+ import type { ParticleDirector } from "../vfx/particleDirector.js";
58
62
  import type { InputSnapshot } from "./inputSnapshot.js";
59
63
  import type { MotionIntents } from "./motionIntents.js";
60
64
  import type { OnDeathSpec } from "../combat/death.js";
@@ -111,6 +115,7 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
111
115
  userId: string;
112
116
  isNew: boolean;
113
117
  };
118
+ territory?: Territory;
114
119
  now?: () => number;
115
120
  occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
116
121
  /**
@@ -367,14 +372,14 @@ export interface GameContextLoot {
367
372
  grantToPlayer(userId: string, drops: Drop[], source?: string): void;
368
373
  }
369
374
  export interface GameContextEconomy {
370
- balance(userId: string, currencyId: string): number;
371
- grant(userId: string, currencyId: string, amount: number): void;
375
+ balance(userId: string, currencyId: string | CurrencyDefinition): number;
376
+ grant(userId: string, currencyId: string | CurrencyDefinition, amount: number): void;
372
377
  /** `options.overdraft` opts this charge into carrying a negative balance (`true` unlimited, `{ max }` capped) — omitted keeps the strict no-debt default. */
373
- charge(userId: string, currencyId: string, amount: number, options?: WalletChargeOptions): {
378
+ charge(userId: string, currencyId: string | CurrencyDefinition, amount: number, options?: WalletChargeOptions): {
374
379
  reason: string;
375
380
  } | null;
376
381
  /** True once `balance(userId, currencyId)` has gone negative under an overdraft-enabled charge. */
377
- isOverdrawn(userId: string, currencyId: string): boolean;
382
+ isOverdrawn(userId: string, currencyId: string | CurrencyDefinition): boolean;
378
383
  }
379
384
  export interface GameContextItemUse {
380
385
  register(handlers: Record<string, ItemUseHandler<GameContext>>): void;
@@ -446,6 +451,7 @@ export interface GameContext {
446
451
  */
447
452
  rng: () => number;
448
453
  game: {
454
+ territory?: Territory;
449
455
  commands: GameContextCommands;
450
456
  events: GameEvents;
451
457
  audio: GameAudio;
@@ -523,8 +529,12 @@ export interface GameContext {
523
529
  weapon: WeaponStats;
524
530
  };
525
531
  time: SimClock;
532
+ /** The simulation stepper (fixed or variable) every driver advances through, plus render-side pose interpolation and per-step stages. */
533
+ sim: SimContext;
526
534
  /** Runtime camera-follow/cinematic override (#196.2); the shell reads `followedEntityId()` each frame. */
527
535
  camera: CameraDirector;
536
+ /** Particle VFX seam (#1659): queue bursts / standing emitters as data; the shell renders them quality-capped. */
537
+ particles: ParticleDirector;
528
538
  /** Held-input snapshot published by the shell each frame before `onTick` (#164.1). */
529
539
  input: InputSnapshot;
530
540
  subscribe(listener: () => void): () => void;
@@ -16,6 +16,13 @@ export type ServerLoopHooks = {
16
16
  export type RuntimeInitContext = {
17
17
  snapshot: GameRuntimeSnapshot;
18
18
  setSnapshot: (snapshot: GameRuntimeSnapshot) => void;
19
+ /**
20
+ * Host wall clock at the start of this call, in ms. The host already knows it, so anything keyed to
21
+ * real time — a UTC date rollover, a `lastTickAt` anchor other code paths read, a seed or a
22
+ * `createdAt` stamp — reads it here instead of re-deriving one by accumulating `dtSeconds` against a
23
+ * persisted epoch. It is authoritative: unlike a `now` carried in command input, no client supplied it.
24
+ */
25
+ nowMs: number;
19
26
  };
20
27
  export type RuntimeLoopContext = RuntimeInitContext & {
21
28
  player: {
@@ -28,6 +35,7 @@ export type RuntimeWorldContext = RuntimeInitContext & {
28
35
  };
29
36
  export type GameRuntimeDefinition = {
30
37
  gameId: string;
38
+ topology?: "shared" | "rooms";
31
39
  save: SaveConfig;
32
40
  commands: Record<string, CommandDef>;
33
41
  loop?: ServerLoopHooks;
@@ -39,9 +47,12 @@ export type HydrateInput = {
39
47
  playersByUserId: Record<string, RuntimePlayerRow>;
40
48
  chunksByKey: Record<string, RuntimeChunkRow>;
41
49
  revision?: number;
50
+ /** Host wall clock for `onInit`, in ms. Defaults to `Date.now()`. */
51
+ nowMs?: number;
42
52
  };
43
53
  export type GameRuntime = {
44
54
  gameId: string;
55
+ topology?: "shared" | "rooms";
45
56
  save: SaveConfig;
46
57
  /**
47
58
  * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and
@@ -50,17 +61,22 @@ export type GameRuntime = {
50
61
  */
51
62
  hasTick: boolean;
52
63
  hydrate: (input: HydrateInput) => GameRuntimeSnapshot;
53
- runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown) => ReturnType<typeof runCommand>;
64
+ runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown,
65
+ /** Host wall clock handed to `validate`/`apply`, in ms. Defaults to `Date.now()`. */
66
+ nowMs?: number) => ReturnType<typeof runCommand>;
54
67
  /**
55
68
  * What a command declares it will touch, evaluated before hydration so a host can load that slice
56
- * and nothing else. `undefined` means the command declared no scope — hydrate everything.
69
+ * and nothing else. Missing scopes load only the actor and no chunks; `{}` explicitly requests all state.
57
70
  */
58
71
  commandScope: (commandName: string, input: unknown, actorUserId: string) => CommandScope | undefined;
59
72
  /** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */
60
73
  joinScope: (userId: string, isNew: boolean) => CommandScope | undefined;
61
- tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number) => GameRuntimeSnapshot;
62
- joinPlayer: (snapshot: GameRuntimeSnapshot, userId: string, isNew: boolean) => GameRuntimeSnapshot;
74
+ /** `nowMs` is the host wall clock at the start of the tick, in ms; defaults to `Date.now()`. */
75
+ tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number, nowMs?: number) => GameRuntimeSnapshot;
76
+ joinPlayer: (snapshot: GameRuntimeSnapshot, userId: string, isNew: boolean, nowMs?: number) => GameRuntimeSnapshot;
63
77
  toProfileRow: (snapshot: GameRuntimeSnapshot, userId: string) => RuntimeProfileRow | null;
64
78
  };
65
79
  /** @internal */
66
80
  export declare function createGameRuntime(definition: GameRuntimeDefinition): GameRuntime;
81
+ /** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */
82
+ export declare function initialPlayerState(runtime: GameRuntime, userId: string, nowMs?: number): RuntimePlayerRow;
@@ -6,6 +6,7 @@ export function createGameRuntime(definition) {
6
6
  let initialized = false;
7
7
  return {
8
8
  gameId: definition.gameId,
9
+ topology: definition.topology,
9
10
  save: definition.save,
10
11
  hasTick: loop?.onTick !== undefined,
11
12
  hydrate(input) {
@@ -23,6 +24,7 @@ export function createGameRuntime(definition) {
23
24
  get snapshot() {
24
25
  return current;
25
26
  },
27
+ nowMs: input.nowMs ?? Date.now(),
26
28
  setSnapshot(next) {
27
29
  current = next;
28
30
  },
@@ -30,8 +32,8 @@ export function createGameRuntime(definition) {
30
32
  }
31
33
  return current;
32
34
  },
33
- runCommand(snapshot, actorUserId, commandName, input) {
34
- return runCommand(snapshot, definition.commands, commandName, input, actorUserId);
35
+ runCommand(snapshot, actorUserId, commandName, input, nowMs) {
36
+ return runCommand(snapshot, definition.commands, commandName, input, actorUserId, nowMs ?? Date.now());
35
37
  },
36
38
  commandScope(commandName, input, actorUserId) {
37
39
  return resolveCommandScope(definition.commands, commandName, input, actorUserId);
@@ -41,7 +43,7 @@ export function createGameRuntime(definition) {
41
43
  return { players: [userId], chunkKeys: [] };
42
44
  return loop.joinScope?.(userId, isNew);
43
45
  },
44
- tick(snapshot, dtSeconds) {
46
+ tick(snapshot, dtSeconds, nowMs) {
45
47
  if (!loop?.onTick)
46
48
  return snapshot;
47
49
  let current = snapshot;
@@ -52,6 +54,7 @@ export function createGameRuntime(definition) {
52
54
  get playerIds() {
53
55
  return Object.keys(current.players);
54
56
  },
57
+ nowMs: nowMs ?? Date.now(),
55
58
  setSnapshot(next) {
56
59
  current = next;
57
60
  },
@@ -59,7 +62,7 @@ export function createGameRuntime(definition) {
59
62
  loop.onTick(ctx, dtSeconds);
60
63
  return current;
61
64
  },
62
- joinPlayer(snapshot, userId, isNew) {
65
+ joinPlayer(snapshot, userId, isNew, nowMs) {
63
66
  const players = { ...snapshot.players };
64
67
  if (!players[userId]) {
65
68
  players[userId] = createEmptyPlayerRow(userId);
@@ -84,6 +87,7 @@ export function createGameRuntime(definition) {
84
87
  return next;
85
88
  },
86
89
  player: { userId, isNew },
90
+ nowMs: nowMs ?? Date.now(),
87
91
  setSnapshot(updated) {
88
92
  next = updated;
89
93
  },
@@ -104,3 +108,12 @@ export function createGameRuntime(definition) {
104
108
  },
105
109
  };
106
110
  }
111
+ /** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */
112
+ export function initialPlayerState(runtime, userId, nowMs = Date.now()) {
113
+ const snapshot = createRuntimeSnapshot({ gameId: runtime.gameId, serverId: "" });
114
+ const initialized = runtime.joinPlayer(snapshot, userId, true, nowMs);
115
+ const player = initialized.players[userId];
116
+ if (!player)
117
+ throw new Error(`onNewPlayer removed player "${userId}"`);
118
+ return structuredClone(player);
119
+ }
@@ -3,6 +3,7 @@ import type { PointerAxisState } from "../input/pointerAxis.js";
3
3
  import type { ModelAssetRef } from "../scene/assetCatalog.js";
4
4
  import { type GameContext, type GameContextContent, type GameContextModels } from "./gameContext.js";
5
5
  import type { InputSnapshot } from "./inputSnapshot.js";
6
+ import { type SimSnapshot } from "./simSnapshot.js";
6
7
  /** One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame. */
7
8
  export interface HeadlessInput {
8
9
  /** Replaces the held-action set for this step; edge detection (`justPressed`/`justReleased`) rolls the previous set forward. Omit to keep the last published set. */
@@ -31,8 +32,12 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
31
32
  * `onTick` and don't need it.
32
33
  */
33
34
  playerMovement?: boolean;
35
+ /** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */
36
+ movement?: import("../game/playableGame.js").PlayerMovementConfig;
34
37
  /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. */
35
38
  heading?: number;
39
+ /** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */
40
+ pitch?: number;
36
41
  /** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */
37
42
  models?: GameContextModels;
38
43
  }
@@ -71,5 +76,10 @@ export interface HeadlessRunner {
71
76
  publishInput(input: HeadlessInput): void;
72
77
  /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
73
78
  readonly ui: HeadlessCommandInterface;
79
+ /** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */
80
+ snapshot(): SimSnapshot;
81
+ restore(snapshot: SimSnapshot): void;
82
+ /** Current simulation tick (`ctx.sim.tick()`). */
83
+ tick(): number;
74
84
  }
75
85
  export declare function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner;