@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.
- package/CHANGELOG.md +114 -2
- package/dist/ai/decisionGraph.d.ts +52 -0
- package/dist/ai/decisionGraph.js +82 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/anim/animGraph.d.ts +144 -0
- package/dist/anim/animGraph.js +286 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +26 -0
- package/dist/anim/locomotionGraph.js +33 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +10 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +89 -0
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +16 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +16 -0
- package/dist/game/cameraConfig.d.ts +8 -0
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +8 -1
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +97 -3
- package/dist/game/systemRuntime.js +6 -1
- package/dist/input/actionBindings.d.ts +2 -1
- package/dist/input/actionBindings.js +10 -8
- package/dist/input/actionContexts.d.ts +21 -0
- package/dist/input/actionContexts.js +56 -0
- package/dist/input/gamepadModel.d.ts +45 -0
- package/dist/input/gamepadModel.js +69 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +85 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/playerMovement.d.ts +10 -2
- package/dist/movement/playerMovement.js +218 -4
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +30 -7
- package/dist/movement/solidObstacles.js +27 -4
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navMesh.d.ts +33 -0
- package/dist/nav/navMesh.js +164 -0
- package/dist/physics/flightDynamics.d.ts +8 -0
- package/dist/physics/kinematicVehicle.d.ts +6 -1
- package/dist/physics/physicsBackend.d.ts +191 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +117 -0
- package/dist/physics/physicsWorldBackend.d.ts +44 -0
- package/dist/physics/physicsWorldBackend.js +485 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +96 -0
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +11 -5
- package/dist/render/lookPreset.js +29 -4
- package/dist/render/postProcessing.d.ts +10 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/commandInput.d.ts +15 -0
- package/dist/runtime/commandInput.js +13 -0
- package/dist/runtime/commandRunner.d.ts +12 -7
- package/dist/runtime/commandRunner.js +7 -5
- package/dist/runtime/gameContext.js +11 -1
- package/dist/runtime/gameContextTypes.d.ts +14 -4
- package/dist/runtime/gameRuntime.d.ts +20 -4
- package/dist/runtime/gameRuntime.js +17 -4
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +8 -0
- package/dist/runtime/inputSnapshot.js +1 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +32 -2
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +19 -1
- package/dist/scene/behaviorRuntime.js +55 -4
- package/dist/scene/behaviors.d.ts +8 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/sensor/perception.d.ts +60 -0
- package/dist/sensor/perception.js +142 -0
- package/dist/settings/graphicsProfile.d.ts +19 -0
- package/dist/settings/graphicsProfile.js +36 -0
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/world/buildings.d.ts +12 -0
- package/dist/world/buildings.js +43 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +132 -0
- package/dist/world/cityKind.js +315 -15
- package/dist/world/features.d.ts +27 -1
- package/dist/world/features.js +7 -2
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +40 -18
- package/skills/jgengine-gameplay/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- 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
|
|
6
|
-
* `"
|
|
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 `"
|
|
36
|
-
*
|
|
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 `"
|
|
12
|
-
*
|
|
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 ?? "
|
|
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 ??
|
|
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
|
-
/**
|
|
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;
|
package/dist/runtime/adapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
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;
|