@jgengine/core 0.18.0 → 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.
- package/CHANGELOG.md +265 -2
- package/dist/ai/decisionGraph.d.ts +96 -0
- package/dist/ai/decisionGraph.js +212 -0
- package/dist/ai/groupAssist.d.ts +10 -0
- package/dist/ai/groupAssist.js +11 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/pursuit.d.ts +1 -1
- package/dist/ai/pursuit.js +1 -1
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/ai/threat.d.ts +8 -0
- package/dist/ai/threat.js +10 -0
- package/dist/anim/animGraph.d.ts +155 -0
- package/dist/anim/animGraph.js +414 -0
- package/dist/anim/boneTexture.d.ts +55 -0
- package/dist/anim/boneTexture.js +43 -0
- package/dist/anim/footPlacement.d.ts +57 -0
- package/dist/anim/footPlacement.js +67 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +35 -0
- package/dist/anim/locomotionGraph.js +62 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +12 -0
- package/dist/audio/doppler.d.ts +21 -0
- package/dist/audio/doppler.js +32 -0
- package/dist/audio/engineLayers.d.ts +83 -0
- package/dist/audio/engineLayers.js +115 -0
- package/dist/combat/abilityKit.d.ts +1 -0
- package/dist/combat/abilityKit.js +1 -0
- package/dist/combat/death.d.ts +7 -0
- package/dist/combat/shotOrigin.d.ts +6 -0
- package/dist/combat/shotOrigin.js +6 -0
- package/dist/combat/weaponFeedback.d.ts +36 -0
- package/dist/combat/weaponFeedback.js +42 -0
- package/dist/combat/weaponHandling.d.ts +161 -0
- package/dist/combat/weaponHandling.js +202 -0
- package/dist/combat/weaponPresentation.d.ts +115 -0
- package/dist/combat/weaponPresentation.js +96 -0
- package/dist/devtools/warnOnce.d.ts +8 -0
- package/dist/devtools/warnOnce.js +18 -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 +92 -1
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +24 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +20 -0
- package/dist/faction/reputation.d.ts +6 -0
- package/dist/faction/reputation.js +11 -0
- package/dist/game/cameraConfig.d.ts +59 -1
- 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 +14 -1
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +16 -0
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +167 -6
- package/dist/game/systemRuntime.js +6 -1
- package/dist/game/toasts.d.ts +8 -0
- package/dist/game/toasts.js +7 -0
- package/dist/game/vfxInstance.d.ts +9 -0
- package/dist/game/vfxInstance.js +12 -1
- package/dist/game/viewports.d.ts +43 -0
- package/dist/game/viewports.js +46 -0
- package/dist/game/worldItem.d.ts +5 -1
- package/dist/game/worldItem.js +0 -1
- package/dist/input/actionBindings.d.ts +17 -2
- package/dist/input/actionBindings.js +24 -11
- package/dist/input/actionContexts.d.ts +45 -0
- package/dist/input/actionContexts.js +113 -0
- package/dist/input/axisInput.d.ts +4 -0
- package/dist/input/axisInput.js +4 -0
- package/dist/input/axisShaper.d.ts +73 -0
- package/dist/input/axisShaper.js +105 -0
- package/dist/input/gamepadModel.d.ts +79 -0
- package/dist/input/gamepadModel.js +108 -0
- package/dist/input/haptics.d.ts +50 -0
- package/dist/input/haptics.js +74 -0
- package/dist/input/inputBuffer.d.ts +40 -0
- package/dist/input/inputBuffer.js +59 -0
- package/dist/input/lookChannel.d.ts +10 -0
- package/dist/input/lookChannel.js +6 -0
- package/dist/input/pointer.d.ts +2 -0
- package/dist/input/pointer.js +4 -0
- package/dist/item/affix.d.ts +6 -0
- package/dist/item/affix.js +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +196 -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/glideModel.d.ts +3 -0
- package/dist/movement/glideModel.js +6 -0
- package/dist/movement/grappleSwing.d.ts +2 -0
- package/dist/movement/grappleSwing.js +5 -1
- package/dist/movement/movementModel.d.ts +39 -0
- package/dist/movement/movementModel.js +62 -19
- package/dist/movement/movementProbe.d.ts +51 -0
- package/dist/movement/movementProbe.js +119 -0
- package/dist/movement/playerMovement.d.ts +30 -3
- package/dist/movement/playerMovement.js +304 -7
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +39 -8
- package/dist/movement/solidObstacles.js +71 -6
- package/dist/movement/voxelController.js +7 -7
- 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/navFromEnvironment.d.ts +9 -3
- package/dist/nav/navFromEnvironment.js +18 -12
- package/dist/nav/navMesh.d.ts +74 -0
- package/dist/nav/navMesh.js +570 -0
- package/dist/physics/aircraftDynamics.d.ts +390 -0
- package/dist/physics/aircraftDynamics.js +704 -0
- package/dist/physics/boatDynamics.d.ts +116 -0
- package/dist/physics/boatDynamics.js +160 -0
- package/dist/physics/drivableVehicle.d.ts +22 -8
- package/dist/physics/drivableVehicle.js +4 -2
- package/dist/physics/flightDynamics.d.ts +29 -4
- package/dist/physics/flightDynamics.js +28 -5
- package/dist/physics/handlingProbe.d.ts +249 -0
- package/dist/physics/handlingProbe.js +606 -0
- package/dist/physics/kinematicVehicle.d.ts +37 -2
- package/dist/physics/kinematicVehicle.js +15 -0
- package/dist/physics/physicsBackend.d.ts +203 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +151 -0
- package/dist/physics/physicsWorldBackend.d.ts +45 -0
- package/dist/physics/physicsWorldBackend.js +518 -0
- package/dist/physics/vehicleBackendLink.d.ts +49 -0
- package/dist/physics/vehicleBackendLink.js +102 -0
- package/dist/physics/vehicleDynamics.d.ts +374 -0
- package/dist/physics/vehicleDynamics.js +737 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +128 -0
- package/dist/puzzle/cellGrid.d.ts +15 -9
- package/dist/puzzle/cellGrid.js +13 -9
- 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 +19 -6
- package/dist/render/lookPreset.js +56 -4
- package/dist/render/postProcessing.d.ts +40 -1
- package/dist/render/postProcessing.js +1 -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/cameraDirector.d.ts +17 -3
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/commandInput.d.ts +52 -0
- package/dist/runtime/commandInput.js +71 -0
- package/dist/runtime/commandRunner.d.ts +39 -7
- package/dist/runtime/commandRunner.js +35 -5
- package/dist/runtime/context/combat.d.ts +1 -0
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/deathLoot.d.ts +2 -0
- package/dist/runtime/context/deathLoot.js +1 -0
- package/dist/runtime/gameContext.js +18 -1
- package/dist/runtime/gameContextTypes.d.ts +33 -7
- package/dist/runtime/gameRuntime.d.ts +23 -5
- package/dist/runtime/gameRuntime.js +21 -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 +14 -0
- package/dist/runtime/inputSnapshot.js +11 -0
- package/dist/runtime/localPlayers.d.ts +73 -0
- package/dist/runtime/localPlayers.js +119 -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 +41 -2
- package/dist/runtime/transport.js +7 -1
- 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 +29 -1
- package/dist/scene/behaviorRuntime.js +71 -4
- package/dist/scene/behaviors.d.ts +12 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/scene/sceneKinds.d.ts +9 -0
- package/dist/scene/vehicleSeat.d.ts +5 -1
- package/dist/scene/vehicleSeat.js +5 -1
- package/dist/sensor/concealment.d.ts +6 -0
- package/dist/sensor/concealment.js +8 -0
- package/dist/sensor/freezeMonitor.d.ts +6 -0
- package/dist/sensor/freezeMonitor.js +8 -0
- package/dist/sensor/perception.d.ts +70 -0
- package/dist/sensor/perception.js +189 -0
- package/dist/sensor/recordingBuffer.d.ts +3 -0
- package/dist/sensor/recordingBuffer.js +8 -0
- package/dist/session/roundState.d.ts +4 -0
- package/dist/session/roundState.js +11 -0
- package/dist/settings/frameRateLimit.d.ts +16 -0
- package/dist/settings/frameRateLimit.js +37 -0
- package/dist/settings/graphicsProfile.d.ts +21 -0
- package/dist/settings/graphicsProfile.js +48 -0
- package/dist/settings/graphicsSettings.d.ts +40 -0
- package/dist/settings/graphicsSettings.js +81 -0
- package/dist/settings/settingsModel.d.ts +9 -1
- package/dist/settings/settingsModel.js +10 -0
- package/dist/stats/eventMeter.d.ts +1 -1
- package/dist/stats/eventMeter.js +1 -1
- 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/feedbackMixer.d.ts +61 -0
- package/dist/vfx/feedbackMixer.js +106 -0
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/visibility/simulationCulling.d.ts +8 -1
- package/dist/visibility/simulationCulling.js +7 -0
- package/dist/visibility/spatialIndex.d.ts +21 -0
- package/dist/visibility/spatialIndex.js +16 -0
- package/dist/world/authoredSolids.d.ts +46 -0
- package/dist/world/authoredSolids.js +75 -0
- package/dist/world/buildingGenerator.js +2 -2
- package/dist/world/buildingKit.d.ts +3 -1
- package/dist/world/buildings.d.ts +46 -2
- package/dist/world/buildings.js +53 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +139 -0
- package/dist/world/cityKind.js +367 -16
- package/dist/world/features.d.ts +56 -3
- package/dist/world/features.js +13 -2
- package/dist/world/footprintGrid.d.ts +7 -0
- package/dist/world/footprintGrid.js +16 -0
- package/dist/world/interiors.d.ts +1 -0
- package/dist/world/interiors.js +5 -3
- package/dist/world/lod.d.ts +6 -0
- package/dist/world/lod.js +8 -0
- 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/terraform.d.ts +8 -0
- package/dist/world/terraform.js +7 -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/walls.d.ts +7 -0
- package/dist/world/walls.js +7 -0
- package/dist/world/worldSolids.d.ts +84 -0
- package/dist/world/worldSolids.js +240 -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 +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-combat/SKILL.md +5 -0
- package/skills/jgengine-combat/api.md +138 -103
- package/skills/jgengine-combat/capabilities.md +29 -1
- package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +501 -453
- package/skills/jgengine-gameplay/capabilities.md +36 -0
- package/skills/jgengine-gameplay/reference-systems.md +4 -0
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
|
@@ -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 = {
|
|
@@ -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,24 @@ 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;
|
|
72
|
+
/** Who may send a command, or `undefined` when the runtime has no command by that name. */
|
|
73
|
+
commandAccess: (commandName: string) => CommandAccess | undefined;
|
|
59
74
|
/** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */
|
|
60
75
|
joinScope: (userId: string, isNew: boolean) => CommandScope | undefined;
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
/** `nowMs` is the host wall clock at the start of the tick, in ms; defaults to `Date.now()`. */
|
|
77
|
+
tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number, nowMs?: number) => GameRuntimeSnapshot;
|
|
78
|
+
joinPlayer: (snapshot: GameRuntimeSnapshot, userId: string, isNew: boolean, nowMs?: number) => GameRuntimeSnapshot;
|
|
63
79
|
toProfileRow: (snapshot: GameRuntimeSnapshot, userId: string) => RuntimeProfileRow | null;
|
|
64
80
|
};
|
|
65
81
|
/** @internal */
|
|
66
82
|
export declare function createGameRuntime(definition: GameRuntimeDefinition): GameRuntime;
|
|
83
|
+
/** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */
|
|
84
|
+
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,18 +32,22 @@ 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);
|
|
38
40
|
},
|
|
41
|
+
commandAccess(commandName) {
|
|
42
|
+
const command = definition.commands[commandName];
|
|
43
|
+
return command === undefined ? undefined : (command.access ?? "client");
|
|
44
|
+
},
|
|
39
45
|
joinScope(userId, isNew) {
|
|
40
46
|
if (!loop?.onNewPlayer)
|
|
41
47
|
return { players: [userId], chunkKeys: [] };
|
|
42
48
|
return loop.joinScope?.(userId, isNew);
|
|
43
49
|
},
|
|
44
|
-
tick(snapshot, dtSeconds) {
|
|
50
|
+
tick(snapshot, dtSeconds, nowMs) {
|
|
45
51
|
if (!loop?.onTick)
|
|
46
52
|
return snapshot;
|
|
47
53
|
let current = snapshot;
|
|
@@ -52,6 +58,7 @@ export function createGameRuntime(definition) {
|
|
|
52
58
|
get playerIds() {
|
|
53
59
|
return Object.keys(current.players);
|
|
54
60
|
},
|
|
61
|
+
nowMs: nowMs ?? Date.now(),
|
|
55
62
|
setSnapshot(next) {
|
|
56
63
|
current = next;
|
|
57
64
|
},
|
|
@@ -59,7 +66,7 @@ export function createGameRuntime(definition) {
|
|
|
59
66
|
loop.onTick(ctx, dtSeconds);
|
|
60
67
|
return current;
|
|
61
68
|
},
|
|
62
|
-
joinPlayer(snapshot, userId, isNew) {
|
|
69
|
+
joinPlayer(snapshot, userId, isNew, nowMs) {
|
|
63
70
|
const players = { ...snapshot.players };
|
|
64
71
|
if (!players[userId]) {
|
|
65
72
|
players[userId] = createEmptyPlayerRow(userId);
|
|
@@ -84,6 +91,7 @@ export function createGameRuntime(definition) {
|
|
|
84
91
|
return next;
|
|
85
92
|
},
|
|
86
93
|
player: { userId, isNew },
|
|
94
|
+
nowMs: nowMs ?? Date.now(),
|
|
87
95
|
setSnapshot(updated) {
|
|
88
96
|
next = updated;
|
|
89
97
|
},
|
|
@@ -104,3 +112,12 @@ export function createGameRuntime(definition) {
|
|
|
104
112
|
},
|
|
105
113
|
};
|
|
106
114
|
}
|
|
115
|
+
/** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */
|
|
116
|
+
export function initialPlayerState(runtime, userId, nowMs = Date.now()) {
|
|
117
|
+
const snapshot = createRuntimeSnapshot({ gameId: runtime.gameId, serverId: "" });
|
|
118
|
+
const initialized = runtime.joinPlayer(snapshot, userId, true, nowMs);
|
|
119
|
+
const player = initialized.players[userId];
|
|
120
|
+
if (!player)
|
|
121
|
+
throw new Error(`onNewPlayer removed player "${userId}"`);
|
|
122
|
+
return structuredClone(player);
|
|
123
|
+
}
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolvePlayerMovementTuning, stepPlayerMovement, } from "../movement/playerMovement.js";
|
|
2
2
|
import { advanceBehaviors } from "../scene/behaviorRuntime.js";
|
|
3
3
|
import { createGameContext } from "./gameContext.js";
|
|
4
|
+
import { createContextSimSnapshot } from "./simSnapshot.js";
|
|
4
5
|
export function createHeadlessRunner(options) {
|
|
5
6
|
const { definition, loop } = options;
|
|
6
7
|
const content = options.content ?? {};
|
|
@@ -15,8 +16,13 @@ export function createHeadlessRunner(options) {
|
|
|
15
16
|
});
|
|
16
17
|
loop?.onInit?.(ctx);
|
|
17
18
|
loop?.onNewPlayer?.(ctx);
|
|
19
|
+
const simSnapshot = createContextSimSnapshot(ctx);
|
|
18
20
|
const tuning = options.playerMovement === true
|
|
19
|
-
? resolvePlayerMovementTuning({
|
|
21
|
+
? resolvePlayerMovementTuning({
|
|
22
|
+
physics: definition.physics,
|
|
23
|
+
world: definition.world,
|
|
24
|
+
movement: options.movement,
|
|
25
|
+
})
|
|
20
26
|
: null;
|
|
21
27
|
function publishInput(input) {
|
|
22
28
|
if (input.held !== undefined)
|
|
@@ -47,13 +53,25 @@ export function createHeadlessRunner(options) {
|
|
|
47
53
|
if (input !== undefined)
|
|
48
54
|
publishInput(input);
|
|
49
55
|
const dt = Math.min(dtSeconds, maxStep);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
let total = 0;
|
|
57
|
+
ctx.sim.advance(dt, (stepDt) => {
|
|
58
|
+
const gameDt = ctx.time.advance(stepDt);
|
|
59
|
+
total += gameDt;
|
|
60
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
61
|
+
if (tuning !== null) {
|
|
62
|
+
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, stepDt, tuning, options.heading, options.pitch);
|
|
63
|
+
}
|
|
64
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
65
|
+
loop?.onTick?.(ctx, gameDt);
|
|
66
|
+
advanceBehaviors(ctx, gameDt);
|
|
67
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
68
|
+
});
|
|
69
|
+
return total;
|
|
70
|
+
},
|
|
71
|
+
snapshot: () => simSnapshot.capture(),
|
|
72
|
+
restore(snapshot) {
|
|
73
|
+
simSnapshot.restore(snapshot);
|
|
57
74
|
},
|
|
75
|
+
tick: () => ctx.sim.tick(),
|
|
58
76
|
};
|
|
59
77
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
|
|
4
4
|
*
|
|
5
5
|
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
6
|
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
@@ -10,7 +10,7 @@ import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export declare const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
12
|
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
-
export declare function validateSlotsPerServer(slotsPerServer: number): {
|
|
13
|
+
export declare function validateSlotsPerServer(slotsPerServer: number, topology?: "rooms" | "shared"): {
|
|
14
14
|
ok: true;
|
|
15
15
|
} | {
|
|
16
16
|
ok: false;
|
|
@@ -75,6 +75,7 @@ export declare function isServerMember(memberUserIds: readonly string[], userId:
|
|
|
75
75
|
* True when the server has no free slots for a non-member. Existing members never count as
|
|
76
76
|
* "full" so rejoin/leave cycles keep working.
|
|
77
77
|
*/
|
|
78
|
+
export declare function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): boolean;
|
|
78
79
|
export declare function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean;
|
|
79
80
|
/**
|
|
80
81
|
* Whether a private-visibility server blocks this join (non-member without a matching code).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
|
|
4
4
|
*
|
|
5
5
|
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
6
|
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
@@ -10,11 +10,11 @@ import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
12
|
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
-
export function validateSlotsPerServer(slotsPerServer) {
|
|
14
|
-
if (!Number.
|
|
13
|
+
export function validateSlotsPerServer(slotsPerServer, topology = "rooms") {
|
|
14
|
+
if (!Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) {
|
|
15
15
|
return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` };
|
|
16
16
|
}
|
|
17
|
-
if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
|
|
17
|
+
if (topology !== "shared" && slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
|
|
18
18
|
return {
|
|
19
19
|
ok: false,
|
|
20
20
|
reason: `slotsPerServer ${slotsPerServer} exceeds JG_MAX_MEMBERS_PER_SERVER (${JG_MAX_MEMBERS_PER_SERVER}). ` +
|
|
@@ -76,12 +76,10 @@ export function canJoinPrivateServer(args) {
|
|
|
76
76
|
export function isServerMember(memberUserIds, userId) {
|
|
77
77
|
return memberUserIds.includes(userId);
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export function isServerFull(memberUserIds, slotsPerServer, userId) {
|
|
84
|
-
return memberUserIds.length >= slotsPerServer && !memberUserIds.includes(userId);
|
|
79
|
+
export function isServerFull(members, slotsPerServer, member) {
|
|
80
|
+
const count = typeof members === "number" ? members : members.length;
|
|
81
|
+
const isMember = typeof member === "boolean" ? member : typeof members !== "number" && members.includes(member);
|
|
82
|
+
return count >= slotsPerServer && !isMember;
|
|
85
83
|
}
|
|
86
84
|
/**
|
|
87
85
|
* Whether a private-visibility server blocks this join (non-member without a matching code).
|
|
@@ -2,6 +2,9 @@ import { syncLifecyclePhase } from "../game/gamePhase.js";
|
|
|
2
2
|
import { advanceBehaviors } from "../scene/behaviorRuntime.js";
|
|
3
3
|
import { createGameContext } from "./gameContext.js";
|
|
4
4
|
import {} from "./inputSnapshot.js";
|
|
5
|
+
import { serverStep } from "../movement/serverStep.js";
|
|
6
|
+
import { resolvePlayerMovementTuning } from "../movement/playerMovement.js";
|
|
7
|
+
import { createInputRecorder } from "./inputRecorder.js";
|
|
5
8
|
import { createWorldReplicator } from "./worldReplication.js";
|
|
6
9
|
/** Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire. */
|
|
7
10
|
export const INPUT_COMMAND = "engine.input";
|
|
@@ -24,7 +27,10 @@ export function createHostedGameRunner(options) {
|
|
|
24
27
|
});
|
|
25
28
|
const members = new Map();
|
|
26
29
|
const inputs = new Map();
|
|
30
|
+
const latestInputs = new Map();
|
|
31
|
+
const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics });
|
|
27
32
|
const inputSeq = new Map();
|
|
33
|
+
let hostTick = 0;
|
|
28
34
|
loop.onInit?.(ctx);
|
|
29
35
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
30
36
|
if (restore !== undefined)
|
|
@@ -49,6 +55,7 @@ export function createHostedGameRunner(options) {
|
|
|
49
55
|
return;
|
|
50
56
|
members.delete(userId);
|
|
51
57
|
inputs.delete(userId);
|
|
58
|
+
latestInputs.delete(userId);
|
|
52
59
|
inputSeq.delete(userId);
|
|
53
60
|
loop.onPlayerLeave?.(ctx, player);
|
|
54
61
|
ctx.game.players?.leave(userId);
|
|
@@ -61,24 +68,45 @@ export function createHostedGameRunner(options) {
|
|
|
61
68
|
return;
|
|
62
69
|
inputSeq.set(userId, seq);
|
|
63
70
|
}
|
|
64
|
-
inputs.
|
|
65
|
-
|
|
71
|
+
let recorder = inputs.get(userId);
|
|
72
|
+
if (recorder === undefined) {
|
|
73
|
+
recorder = createInputRecorder();
|
|
74
|
+
inputs.set(userId, recorder);
|
|
75
|
+
}
|
|
76
|
+
recorder.record(frame.tick ?? ctx.sim.tick() + 1, frame);
|
|
77
|
+
latestInputs.set(userId, frame);
|
|
66
78
|
},
|
|
67
79
|
heldInput(userId) {
|
|
68
|
-
return
|
|
80
|
+
return latestInputs.get(userId) ?? null;
|
|
69
81
|
},
|
|
70
82
|
command(userId, name, input) {
|
|
71
83
|
return ctx.game.commands.runAs(userId, name, input);
|
|
72
84
|
},
|
|
73
85
|
tick(dt) {
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
hostTick += 1;
|
|
87
|
+
ctx.sim.advance(dt, (stepDt, tick) => {
|
|
88
|
+
for (const [userId, recorder] of inputs) {
|
|
89
|
+
const frame = recorder.frameAt(tick);
|
|
90
|
+
if (frame !== null)
|
|
91
|
+
ctx.game.players?.setInput(userId, frame);
|
|
92
|
+
}
|
|
93
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
94
|
+
for (const userId of members.keys()) {
|
|
95
|
+
const frame = inputs.get(userId)?.frameAt(tick) ?? null;
|
|
96
|
+
if (frame !== null)
|
|
97
|
+
serverStep(ctx, userId, frame, stepDt, movementTuning);
|
|
98
|
+
}
|
|
99
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
100
|
+
loop.onTick?.(ctx, stepDt);
|
|
101
|
+
advanceBehaviors(ctx, stepDt);
|
|
102
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
103
|
+
});
|
|
76
104
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
77
105
|
return replicator.commit();
|
|
78
106
|
},
|
|
79
107
|
diff: (sinceRevision) => replicator.diff(sinceRevision),
|
|
80
108
|
revision: () => replicator.revision(),
|
|
81
|
-
snapshot: (viewer) => ctx.snapshot(viewer),
|
|
109
|
+
snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }),
|
|
82
110
|
projectsViewers: () => ctx.replicatesPerViewer(),
|
|
83
111
|
members: () => Array.from(members.keys()),
|
|
84
112
|
context: () => ctx,
|
|
@@ -5,24 +5,14 @@ import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
|
|
|
5
5
|
import { type HostedGameRunner, type InputFrame } from "./hostedGameRunner.js";
|
|
6
6
|
import type { WorldDiff } from "./worldReplication.js";
|
|
7
7
|
import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
snapshot: WorldSnapshot;
|
|
11
|
-
revision: number;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
|
|
15
|
-
* Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
|
|
16
|
-
* saves on a cadence; a stateless host reconstructs from `load()` each invocation.
|
|
17
|
-
*/
|
|
18
|
-
export interface HostedWorldStore {
|
|
19
|
-
load(): HostedWorldRecord | null;
|
|
20
|
-
save(record: HostedWorldRecord): void;
|
|
21
|
-
}
|
|
8
|
+
import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
9
|
+
export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
22
10
|
/** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host.
|
|
23
11
|
* @internal
|
|
24
12
|
*/
|
|
25
|
-
export declare function memoryWorldStore(seed?: HostedWorldRecord):
|
|
13
|
+
export declare function memoryWorldStore(seed?: HostedWorldRecord): SyncHostedWorldStore;
|
|
14
|
+
/** Async in-process store for callers exercising the production persistence contract. */
|
|
15
|
+
export declare function asyncMemoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore;
|
|
26
16
|
/** A client replication pull: a full baseline (first sync / fell behind) or a diff since the client's cursor. */
|
|
27
17
|
export type HostedWorldSync = {
|
|
28
18
|
kind: "baseline";
|
|
@@ -39,7 +29,7 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
|
|
|
39
29
|
host?: LoopPlayer;
|
|
40
30
|
now?: () => number;
|
|
41
31
|
/** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
|
|
42
|
-
store?:
|
|
32
|
+
store?: SyncHostedWorldStore;
|
|
43
33
|
/** Minimum elapsed ms between auto-saves on tick, measured against `now` (defaults to `Date.now` when omitted). Default `0` — persist on every revision-changing tick. */
|
|
44
34
|
saveIntervalMs?: number;
|
|
45
35
|
/** Render-model lookup for collider auto-fit, forwarded to the runner — see {@link HostedGameRunnerOptions.models}. */
|
|
@@ -59,6 +49,8 @@ export interface HostedWorldSession {
|
|
|
59
49
|
tick(dt: number): number;
|
|
60
50
|
/** Replication pull: `null`/`0` cursor → full baseline; a prior revision → a diff since it. */
|
|
61
51
|
sync(sinceRevision: number | null): HostedWorldSync;
|
|
52
|
+
/** Alias used by host transports when pulling the next baseline or diff for a subscriber. */
|
|
53
|
+
pull(sinceRevision: number | null): HostedWorldSync;
|
|
62
54
|
/** The full world baseline projected for one viewer (private/AOI). Identity when no replication policy is set. */
|
|
63
55
|
snapshotFor(viewer: SnapshotViewer): WorldSnapshot;
|
|
64
56
|
/** True when {@link snapshotFor} is viewer-dependent — a host must serve each connection its own projected frame instead of a shared diff. */
|
|
@@ -73,3 +65,7 @@ export interface HostedWorldSession {
|
|
|
73
65
|
* @internal
|
|
74
66
|
*/
|
|
75
67
|
export declare function createHostedWorldSession<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedWorldSessionOptions<TAssetRef, TMultiplayer>): HostedWorldSession;
|
|
68
|
+
/** Build a hosted session from an asynchronous persistence backend. */
|
|
69
|
+
export declare function createHostedWorldSessionAsync<TAssetRef extends ModelAssetRef, TMultiplayer>(options: Omit<HostedWorldSessionOptions<TAssetRef, TMultiplayer>, "store"> & {
|
|
70
|
+
store: HostedWorldStore;
|
|
71
|
+
}): Promise<HostedWorldSession>;
|
|
@@ -11,6 +11,14 @@ export function memoryWorldStore(seed) {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
/** Async in-process store for callers exercising the production persistence contract. */
|
|
15
|
+
export function asyncMemoryWorldStore(seed) {
|
|
16
|
+
let record = seed ?? null;
|
|
17
|
+
return {
|
|
18
|
+
async load() { return record; },
|
|
19
|
+
async save(next) { record = { snapshot: next.snapshot, revision: next.revision }; },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
14
22
|
/** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
|
|
15
23
|
* @internal
|
|
16
24
|
*/
|
|
@@ -33,6 +41,12 @@ export function createHostedWorldSession(options) {
|
|
|
33
41
|
savedRevision = runner.revision();
|
|
34
42
|
store.save({ snapshot: runner.snapshot(), revision: savedRevision });
|
|
35
43
|
}
|
|
44
|
+
const pull = (sinceRevision) => {
|
|
45
|
+
if (sinceRevision === null || sinceRevision <= 0) {
|
|
46
|
+
return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
|
|
47
|
+
}
|
|
48
|
+
return { kind: "diff", diff: runner.diff(sinceRevision) };
|
|
49
|
+
};
|
|
36
50
|
return {
|
|
37
51
|
join: (userId, isNew) => runner.join(userId, isNew),
|
|
38
52
|
leave: (userId) => runner.leave(userId),
|
|
@@ -49,12 +63,8 @@ export function createHostedWorldSession(options) {
|
|
|
49
63
|
}
|
|
50
64
|
return revision;
|
|
51
65
|
},
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
|
|
55
|
-
}
|
|
56
|
-
return { kind: "diff", diff: runner.diff(sinceRevision) };
|
|
57
|
-
},
|
|
66
|
+
pull,
|
|
67
|
+
sync: pull,
|
|
58
68
|
snapshotFor: (viewer) => runner.snapshot(viewer),
|
|
59
69
|
projectsViewers: () => runner.projectsViewers(),
|
|
60
70
|
revision: () => runner.revision(),
|
|
@@ -63,3 +73,16 @@ export function createHostedWorldSession(options) {
|
|
|
63
73
|
runner: () => runner,
|
|
64
74
|
};
|
|
65
75
|
}
|
|
76
|
+
/** Build a hosted session from an asynchronous persistence backend. */
|
|
77
|
+
export async function createHostedWorldSessionAsync(options) {
|
|
78
|
+
const { store, ...sessionOptions } = options;
|
|
79
|
+
const loaded = await store.load();
|
|
80
|
+
const session = createHostedWorldSession({
|
|
81
|
+
...sessionOptions,
|
|
82
|
+
store: {
|
|
83
|
+
load: () => loaded,
|
|
84
|
+
save: (record) => { void store.save(record); },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorldSnapshot } from "./worldSnapshot.js";
|
|
2
|
+
/** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */
|
|
3
|
+
export interface HostedWorldRecord {
|
|
4
|
+
snapshot: WorldSnapshot;
|
|
5
|
+
revision: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
|
|
9
|
+
* Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
|
|
10
|
+
* saves on a cadence; a stateless host reconstructs from `load()` each invocation.
|
|
11
|
+
*/
|
|
12
|
+
export interface HostedWorldStore {
|
|
13
|
+
load(): Promise<HostedWorldRecord | null>;
|
|
14
|
+
save(record: HostedWorldRecord): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** Synchronous store adapter retained for deterministic in-process tests. */
|
|
17
|
+
export interface SyncHostedWorldStore {
|
|
18
|
+
load(): HostedWorldRecord | null;
|
|
19
|
+
save(record: HostedWorldRecord): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { InputFrame } from "./inputSnapshot.js";
|
|
2
|
+
/** An input frame and the first tick it applies to. */
|
|
3
|
+
export interface RecordedInput {
|
|
4
|
+
tick: number;
|
|
5
|
+
frame: InputFrame;
|
|
6
|
+
}
|
|
7
|
+
/** Serializable recorder contents, sorted by tick. */
|
|
8
|
+
export interface InputRecorderState {
|
|
9
|
+
frames: RecordedInput[];
|
|
10
|
+
}
|
|
11
|
+
/** Tick-indexed input log: record per tick, look up the frame in force at any tick. */
|
|
12
|
+
export interface InputRecorder {
|
|
13
|
+
/** Store the frame in force from `tick` onward. Recording the same tick twice replaces the earlier frame. */
|
|
14
|
+
record(tick: number, frame: InputFrame): void;
|
|
15
|
+
/** The frame in force at `tick`: the latest recorded frame whose tick is at most `tick`, or `null` before the first. */
|
|
16
|
+
frameAt(tick: number): InputFrame | null;
|
|
17
|
+
frames(): readonly RecordedInput[];
|
|
18
|
+
/** Highest recorded tick, or `-1` when empty. */
|
|
19
|
+
lastTick(): number;
|
|
20
|
+
clear(): void;
|
|
21
|
+
snapshot(): InputRecorderState;
|
|
22
|
+
restore(state: InputRecorderState): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
|
|
26
|
+
* into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
|
|
27
|
+
*
|
|
28
|
+
* @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
|
|
29
|
+
*/
|
|
30
|
+
export declare function createInputRecorder(): InputRecorder;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function cloneFrame(frame) {
|
|
2
|
+
return {
|
|
3
|
+
held: [...frame.held],
|
|
4
|
+
pointer: frame.pointer === null ? null : { ...frame.pointer },
|
|
5
|
+
...(frame.analog === undefined ? {} : { analog: frame.analog === null ? null : { ...frame.analog } }),
|
|
6
|
+
...(frame.tick === undefined ? {} : { tick: frame.tick }),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
|
|
11
|
+
* into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
|
|
12
|
+
*
|
|
13
|
+
* @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
|
|
14
|
+
*/
|
|
15
|
+
export function createInputRecorder() {
|
|
16
|
+
let frames = [];
|
|
17
|
+
function indexAtOrBefore(tick) {
|
|
18
|
+
let lo = 0;
|
|
19
|
+
let hi = frames.length - 1;
|
|
20
|
+
let found = -1;
|
|
21
|
+
while (lo <= hi) {
|
|
22
|
+
const mid = (lo + hi) >> 1;
|
|
23
|
+
if (frames[mid].tick <= tick) {
|
|
24
|
+
found = mid;
|
|
25
|
+
lo = mid + 1;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
hi = mid - 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
record(tick, frame) {
|
|
35
|
+
const entry = { tick, frame: cloneFrame(frame) };
|
|
36
|
+
const index = indexAtOrBefore(tick);
|
|
37
|
+
if (index >= 0 && frames[index].tick === tick) {
|
|
38
|
+
frames[index] = entry;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
frames.splice(index + 1, 0, entry);
|
|
42
|
+
},
|
|
43
|
+
frameAt(tick) {
|
|
44
|
+
const index = indexAtOrBefore(tick);
|
|
45
|
+
return index < 0 ? null : frames[index].frame;
|
|
46
|
+
},
|
|
47
|
+
frames: () => frames,
|
|
48
|
+
lastTick: () => (frames.length === 0 ? -1 : frames[frames.length - 1].tick),
|
|
49
|
+
clear() {
|
|
50
|
+
frames = [];
|
|
51
|
+
},
|
|
52
|
+
snapshot: () => ({ frames: frames.map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) })) }),
|
|
53
|
+
restore(state) {
|
|
54
|
+
frames = state.frames
|
|
55
|
+
.map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) }))
|
|
56
|
+
.sort((a, b) => a.tick - b.tick);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -1,13 +1,27 @@
|
|
|
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[];
|
|
6
7
|
pointer: PointerAxisState | null;
|
|
7
8
|
/** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
|
|
8
9
|
analog?: Readonly<Record<string, number>> | null;
|
|
10
|
+
/** Simulation tick (`ctx.sim.tick()`) the frame was captured for; hosts and replay align inputs on it. Absent from senders that predate the fixed-step loop. */
|
|
11
|
+
tick?: number;
|
|
9
12
|
}
|
|
10
13
|
export interface InputSnapshot {
|
|
14
|
+
/** Trigger controller vibration for a local user's gamepad when supported. */
|
|
15
|
+
rumble(userId: string, options: {
|
|
16
|
+
strong: number;
|
|
17
|
+
weak: number;
|
|
18
|
+
ms: number;
|
|
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;
|
|
11
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. */
|
|
12
26
|
publish(held: readonly string[]): void;
|
|
13
27
|
/** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
|