@jgengine/core 0.19.0 → 0.22.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 +364 -0
- package/dist/ai/decisionGraph.d.ts +2 -0
- package/dist/ai/decisionGraph.js +6 -0
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/anim/animGraph.d.ts +33 -2
- package/dist/anim/animGraph.js +200 -12
- package/dist/anim/locomotionGraph.d.ts +11 -3
- package/dist/anim/locomotionGraph.js +25 -15
- package/dist/audio/audioFalloff.d.ts +4 -0
- package/dist/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +19 -0
- package/dist/combat/abilityKit.js +40 -4
- package/dist/combat/death.d.ts +4 -3
- package/dist/combat/death.js +3 -3
- package/dist/combat/magazine.d.ts +9 -0
- package/dist/combat/magazine.js +24 -2
- package/dist/combat/projectiles.d.ts +94 -2
- package/dist/combat/projectiles.js +282 -61
- package/dist/combat/regenShield.d.ts +14 -0
- package/dist/combat/regenShield.js +23 -7
- package/dist/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/economy/auctionBook.d.ts +1 -1
- package/dist/economy/auctionBook.js +11 -1
- package/dist/economy/listingBook.d.ts +1 -1
- package/dist/economy/listingBook.js +11 -1
- package/dist/economy/resourceLedger.js +3 -3
- package/dist/economy/shopStock.js +7 -6
- package/dist/economy/techTree.js +7 -6
- package/dist/economy/unlockPoints.js +3 -6
- package/dist/editor/commandApply.js +89 -16
- package/dist/editor/commands.d.ts +38 -1
- package/dist/editor/commands.js +320 -3
- package/dist/editor/creatorStorage.d.ts +59 -0
- package/dist/editor/creatorStorage.js +164 -0
- package/dist/editor/document.d.ts +4 -0
- package/dist/editor/document.js +116 -17
- package/dist/editor/index.d.ts +3 -0
- package/dist/editor/index.js +1 -0
- package/dist/editor/liveSync.d.ts +4 -1
- package/dist/editor/liveSync.js +26 -10
- package/dist/editor/materialAuthoring.d.ts +14 -0
- package/dist/editor/materialAuthoring.js +38 -0
- package/dist/editor/modelNodeVisibility.d.ts +11 -0
- package/dist/editor/modelNodeVisibility.js +29 -0
- package/dist/editor/movementCatalog.d.ts +90 -0
- package/dist/editor/movementCatalog.js +144 -0
- package/dist/editor/simulation.d.ts +60 -0
- package/dist/editor/simulation.js +202 -0
- package/dist/editor/staticPrefab.d.ts +17 -0
- package/dist/editor/staticPrefab.js +51 -0
- package/dist/editor/terrainMaterials.d.ts +10 -0
- package/dist/editor/terrainMaterials.js +13 -0
- package/dist/editor/types.d.ts +11 -0
- package/dist/game/cameraConfig.d.ts +3 -1
- package/dist/game/clipRoles.d.ts +2 -1
- package/dist/game/clipRoles.js +7 -3
- package/dist/game/contentValidation.d.ts +69 -0
- package/dist/game/contentValidation.js +118 -0
- package/dist/game/controlGate.d.ts +8 -0
- package/dist/game/controlGate.js +43 -6
- package/dist/game/defineGame.d.ts +32 -3
- package/dist/game/dialogueGraph.d.ts +20 -0
- package/dist/game/dialogueGraph.js +107 -0
- package/dist/game/events.d.ts +5 -0
- package/dist/game/keyValueStore.d.ts +17 -4
- package/dist/game/keyValueStore.js +35 -23
- package/dist/game/lootModifiers.d.ts +17 -0
- package/dist/game/lootModifiers.js +26 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +52 -20
- package/dist/game/quest.d.ts +15 -0
- package/dist/game/quest.js +209 -63
- package/dist/game/questCatalog.d.ts +50 -0
- package/dist/game/questCatalog.js +205 -0
- package/dist/game/saveStore.d.ts +6 -5
- package/dist/game/saveStore.js +57 -57
- package/dist/game/systemRuntime.d.ts +2 -2
- package/dist/game/systemRuntime.js +84 -33
- package/dist/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/interaction/promptRegistry.d.ts +5 -4
- package/dist/interaction/promptRegistry.js +8 -2
- package/dist/inventory/inventoryModel.js +9 -6
- package/dist/inventory/shapedGrid.d.ts +48 -9
- package/dist/inventory/shapedGrid.js +69 -10
- package/dist/item/durability.js +4 -0
- package/dist/item/itemInstanceRegistry.d.ts +32 -1
- package/dist/item/itemInstanceRegistry.js +111 -7
- package/dist/item/itemgen.d.ts +1 -1
- package/dist/item/itemgen.js +1 -1
- package/dist/item/modularItem.js +7 -1
- package/dist/material/appearanceSignals.d.ts +53 -0
- package/dist/material/appearanceSignals.js +47 -0
- package/dist/material/hairCards.d.ts +47 -0
- package/dist/material/hairCards.js +136 -0
- package/dist/material/materialAsset.d.ts +162 -0
- package/dist/material/materialAsset.js +382 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +348 -1
- package/dist/movement/characterController.d.ts +5 -2
- package/dist/movement/characterController.js +50 -23
- package/dist/movement/freeFlight.d.ts +2 -2
- package/dist/movement/freeFlight.js +3 -3
- package/dist/movement/movementModel.d.ts +24 -1
- package/dist/movement/movementModel.js +74 -7
- package/dist/movement/playerMovement.d.ts +28 -1
- package/dist/movement/playerMovement.js +425 -102
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +3 -1
- package/dist/movement/solidObstacles.js +8 -5
- package/dist/movement/terrainGrade.d.ts +13 -0
- package/dist/movement/terrainGrade.js +33 -0
- package/dist/movement/voxelController.d.ts +2 -2
- package/dist/movement/voxelController.js +3 -3
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/kinematicVehicle.d.ts +8 -3
- package/dist/physics/kinematicVehicle.js +2 -2
- package/dist/physics/physicsWorldBackend.js +19 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleDynamics.d.ts +3 -2
- package/dist/physics/vehicleDynamics.js +2 -2
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/runtime/cameraDirector.d.ts +39 -2
- package/dist/runtime/cameraDirector.js +162 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/context/combat.d.ts +11 -4
- package/dist/runtime/context/combat.js +178 -11
- package/dist/runtime/context/deathLoot.d.ts +9 -8
- package/dist/runtime/context/deathLoot.js +16 -16
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +13 -21
- package/dist/runtime/descriptors/baseline.js +34 -3
- package/dist/runtime/descriptors/features.d.ts +2 -1
- package/dist/runtime/descriptors/features.js +18 -0
- package/dist/runtime/gameContext.js +79 -13
- package/dist/runtime/gameContextTypes.d.ts +14 -0
- package/dist/runtime/headlessRunner.d.ts +3 -1
- package/dist/runtime/headlessRunner.js +11 -11
- package/dist/runtime/hostedGameRunner.d.ts +24 -1
- package/dist/runtime/hostedGameRunner.js +156 -10
- package/dist/runtime/hostedWorldSession.d.ts +17 -5
- package/dist/runtime/hostedWorldSession.js +154 -16
- package/dist/runtime/hostedWorldStore.d.ts +2 -2
- package/dist/runtime/inputRecorder.d.ts +1 -1
- package/dist/runtime/inputRecorder.js +21 -1
- package/dist/runtime/inputSnapshot.d.ts +16 -4
- package/dist/runtime/inputSnapshot.js +31 -3
- package/dist/runtime/runtimeSave.d.ts +10 -6
- package/dist/runtime/runtimeSave.js +11 -5
- package/dist/runtime/simContext.d.ts +8 -3
- package/dist/runtime/simContext.js +7 -3
- package/dist/runtime/simSnapshot.js +2 -0
- package/dist/runtime/worldReplication.d.ts +2 -0
- package/dist/runtime/worldReplication.js +1 -1
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/behaviorRuntime.d.ts +26 -5
- package/dist/scene/behaviorRuntime.js +263 -16
- package/dist/scene/behaviors.d.ts +30 -1
- package/dist/scene/behaviors.js +29 -0
- package/dist/scene/entityStore.d.ts +14 -3
- package/dist/scene/entityStore.js +45 -9
- package/dist/scene/mount.d.ts +9 -0
- package/dist/scene/mount.js +35 -0
- package/dist/scene/possession.d.ts +2 -0
- package/dist/scene/possession.js +21 -1
- package/dist/scene/sceneRaycast.d.ts +7 -0
- package/dist/scene/sceneRaycast.js +55 -0
- package/dist/scene/spatial.d.ts +20 -1
- package/dist/scene/spatial.js +94 -2
- package/dist/scene/vehicleSeat.d.ts +36 -2
- package/dist/scene/vehicleSeat.js +89 -2
- package/dist/stats/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +9 -2
- package/dist/stats/eventMeter.js +5 -2
- package/dist/store/defineStore.d.ts +3 -1
- package/dist/store/defineStore.js +3 -1
- package/dist/store/observableKeyedStore.d.ts +1 -1
- package/dist/store/observableKeyedStore.js +8 -6
- package/dist/time/simClock.d.ts +6 -2
- package/dist/time/simClock.js +5 -0
- package/dist/vfx/particleDirector.d.ts +58 -6
- package/dist/vfx/particleDirector.js +113 -22
- package/dist/vfx/particles.d.ts +68 -5
- package/dist/vfx/particles.js +284 -32
- package/dist/visibility/assetStreaming.d.ts +21 -3
- package/dist/visibility/assetStreaming.js +108 -24
- package/dist/visibility/settings.d.ts +8 -2
- package/dist/visibility/settings.js +3 -0
- package/dist/visibility/visibilitySystem.js +1 -0
- package/dist/work/expedition.d.ts +110 -0
- package/dist/work/expedition.js +195 -0
- package/dist/work/staffedStation.d.ts +29 -0
- package/dist/work/staffedStation.js +27 -0
- package/dist/world/authoredAnimation.d.ts +38 -0
- package/dist/world/authoredAnimation.js +99 -0
- package/dist/world/authoredObjects.d.ts +24 -4
- package/dist/world/authoredObjects.js +43 -16
- package/dist/world/authoredSimulation.d.ts +74 -0
- package/dist/world/authoredSimulation.js +302 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingKit.d.ts +8 -3
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +3 -3
- package/dist/world/footprintGrid.d.ts +55 -0
- package/dist/world/footprintGrid.js +201 -0
- package/dist/world/grassKind.d.ts +10 -0
- package/dist/world/grassKind.js +22 -0
- package/dist/world/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +2 -2
- package/dist/world/terraform.js +46 -18
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +11 -0
- package/dist/world/worldSolids.js +112 -1
- package/dist/world.d.ts +5 -5
- package/dist/world.js +4 -4
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-combat/SKILL.md +8 -2
- package/skills/jgengine-combat/api.md +22 -10
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +131 -37
- package/skills/jgengine-gameplay/capabilities.md +68 -0
- package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
- package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
- package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
- package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
- package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
- package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
- package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
- package/skills/jgengine-gameplay/reference-systems.md +119 -0
- package/skills/jgengine-world/SKILL.md +12 -3
- package/skills/jgengine-world/api.md +201 -58
- package/skills/jgengine-world/capabilities.md +116 -3
- package/skills/jgengine-world/recipes/character-animation.md +31 -8
- package/skills/jgengine-world/recipes/controls.md +4 -0
- package/skills/jgengine-world/recipes/npc-senses.md +12 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
- package/skills/jgengine-world/reference.md +167 -5
- package/skills/jgengine-world/visibility.md +28 -1
|
@@ -7,7 +7,12 @@ import type { WorldDiff } from "./worldReplication.js";
|
|
|
7
7
|
import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
|
|
8
8
|
import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
9
9
|
export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
10
|
-
/**
|
|
10
|
+
/** @internal Decode the retry envelope shared by authoritative hosts. */
|
|
11
|
+
export declare function unwrapOpEnvelope(input: unknown): {
|
|
12
|
+
opId: string;
|
|
13
|
+
input: unknown;
|
|
14
|
+
} | null;
|
|
15
|
+
/** Detached in-process {@link SyncHostedWorldStore} for tests, local play, and the browser-tab P2P host.
|
|
11
16
|
* @internal
|
|
12
17
|
*/
|
|
13
18
|
export declare function memoryWorldStore(seed?: HostedWorldRecord): SyncHostedWorldStore;
|
|
@@ -30,8 +35,10 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
|
|
|
30
35
|
now?: () => number;
|
|
31
36
|
/** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
|
|
32
37
|
store?: SyncHostedWorldStore;
|
|
33
|
-
/** Minimum elapsed ms between
|
|
38
|
+
/** Minimum elapsed ms between automatic saves, measured against `now` (default `Date.now`). Default `0`; private clock/simulation changes also count. A pending write defers automatic capture until a later tick. */
|
|
34
39
|
saveIntervalMs?: number;
|
|
40
|
+
/** Retained retry receipts per player. Default `64`; retries after eviction are new operations. */
|
|
41
|
+
operationHistoryLimit?: number;
|
|
35
42
|
/** Render-model lookup for collider auto-fit, forwarded to the runner — see {@link HostedGameRunnerOptions.models}. */
|
|
36
43
|
models?: GameContextModels;
|
|
37
44
|
}
|
|
@@ -43,11 +50,14 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
|
|
|
43
50
|
*/
|
|
44
51
|
export interface HostedWorldSession {
|
|
45
52
|
join(userId: string, isNew: boolean): void;
|
|
53
|
+
/** Whether this identity has joined before, including across leave/restart. */
|
|
54
|
+
hasPlayer(userId: string): boolean;
|
|
46
55
|
leave(userId: string): void;
|
|
47
56
|
input(userId: string, frame: InputFrame): void;
|
|
48
|
-
|
|
57
|
+
/** Optional stable operation id deduplicates retries across reconnect/restart; the receipt persists atomically with state. */
|
|
58
|
+
command(userId: string, name: string, input: unknown, operationId?: string): CommandResult<GameContext>;
|
|
49
59
|
tick(dt: number): number;
|
|
50
|
-
/** Replication pull:
|
|
60
|
+
/** Replication pull: a fresh, pre-restart or future cursor receives a baseline; an in-session revision receives a diff. */
|
|
51
61
|
sync(sinceRevision: number | null): HostedWorldSync;
|
|
52
62
|
/** Alias used by host transports when pulling the next baseline or diff for a subscriber. */
|
|
53
63
|
pull(sinceRevision: number | null): HostedWorldSync;
|
|
@@ -58,7 +68,9 @@ export interface HostedWorldSession {
|
|
|
58
68
|
revision(): number;
|
|
59
69
|
members(): readonly string[];
|
|
60
70
|
/** Force-persist the current world to the store. */
|
|
61
|
-
save(): void
|
|
71
|
+
save(): void | Promise<void>;
|
|
72
|
+
/** Most recent automatic save failure; cleared by a successful write. Explicit `save()` also rejects. */
|
|
73
|
+
persistenceError(): unknown | null;
|
|
62
74
|
runner(): HostedGameRunner;
|
|
63
75
|
}
|
|
64
76
|
/** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
|
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { createHostedGameRunner } from "./hostedGameRunner.js";
|
|
2
|
-
|
|
2
|
+
import { applyWorldSnapshot } from "./worldSnapshot.js";
|
|
3
|
+
/** @internal Decode the retry envelope shared by authoritative hosts. */
|
|
4
|
+
export function unwrapOpEnvelope(input) {
|
|
5
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
6
|
+
return null;
|
|
7
|
+
const record = input;
|
|
8
|
+
const opId = record["__jgWsOpId"];
|
|
9
|
+
if (typeof opId !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const { __jgWsOpId: _opId, ...rest } = record;
|
|
12
|
+
return { opId, input: rest };
|
|
13
|
+
}
|
|
14
|
+
/** Detached in-process {@link SyncHostedWorldStore} for tests, local play, and the browser-tab P2P host.
|
|
3
15
|
* @internal
|
|
4
16
|
*/
|
|
5
17
|
export function memoryWorldStore(seed) {
|
|
6
|
-
let record = seed
|
|
18
|
+
let record = seed === undefined ? null : structuredClone(seed);
|
|
7
19
|
return {
|
|
8
|
-
load: () => record,
|
|
20
|
+
load: () => record === null ? null : structuredClone(record),
|
|
9
21
|
save(next) {
|
|
10
|
-
record =
|
|
22
|
+
record = structuredClone(next);
|
|
11
23
|
},
|
|
12
24
|
};
|
|
13
25
|
}
|
|
14
26
|
/** Async in-process store for callers exercising the production persistence contract. */
|
|
15
27
|
export function asyncMemoryWorldStore(seed) {
|
|
16
|
-
let record = seed
|
|
28
|
+
let record = seed === undefined ? null : structuredClone(seed);
|
|
17
29
|
return {
|
|
18
|
-
async load() { return record; },
|
|
19
|
-
async save(next) { record =
|
|
30
|
+
async load() { return record === null ? null : structuredClone(record); },
|
|
31
|
+
async save(next) { record = structuredClone(next); },
|
|
20
32
|
};
|
|
21
33
|
}
|
|
22
34
|
/** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
|
|
@@ -33,32 +45,157 @@ export function createHostedWorldSession(options) {
|
|
|
33
45
|
...(host === undefined ? {} : { host }),
|
|
34
46
|
...(now === undefined ? {} : { now }),
|
|
35
47
|
...(loaded === null ? {} : { restore: loaded.snapshot }),
|
|
48
|
+
revision: loaded?.revision ?? 0,
|
|
36
49
|
...(options.models === undefined ? {} : { models: options.models }),
|
|
37
50
|
});
|
|
51
|
+
const knownPlayers = new Set();
|
|
52
|
+
const operations = new Map();
|
|
53
|
+
const configuredHistory = options.operationHistoryLimit ?? 64;
|
|
54
|
+
const historyLimit = Number.isFinite(configuredHistory) ? Math.max(1, Math.floor(configuredHistory)) : 64;
|
|
55
|
+
const sessionModule = {
|
|
56
|
+
key: "hostSession",
|
|
57
|
+
snapshot: () => ({ players: [...knownPlayers], operations: [...operations] }),
|
|
58
|
+
hydrate(state) {
|
|
59
|
+
knownPlayers.clear();
|
|
60
|
+
for (const userId of state.players)
|
|
61
|
+
knownPlayers.add(userId);
|
|
62
|
+
operations.clear();
|
|
63
|
+
for (const [userId, receipts] of state.operations)
|
|
64
|
+
operations.set(userId, receipts.slice(-historyLimit));
|
|
65
|
+
},
|
|
66
|
+
decode(raw) {
|
|
67
|
+
if (raw === null || typeof raw !== "object" || !("players" in raw) || !("operations" in raw))
|
|
68
|
+
return null;
|
|
69
|
+
if (!Array.isArray(raw.players) || !raw.players.every((id) => typeof id === "string") || !Array.isArray(raw.operations))
|
|
70
|
+
return null;
|
|
71
|
+
for (const entry of raw.operations) {
|
|
72
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || !Array.isArray(entry[1]))
|
|
73
|
+
return null;
|
|
74
|
+
for (const receipt of entry[1]) {
|
|
75
|
+
if (receipt === null || typeof receipt !== "object" || typeof receipt.id !== "string" ||
|
|
76
|
+
typeof receipt.command !== "string" || typeof receipt.input !== "string" ||
|
|
77
|
+
!["applied", "rejected", "unknown-command"].includes(receipt.outcome) ||
|
|
78
|
+
(receipt.outcome === "rejected" && typeof receipt.reason !== "string"))
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return raw;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
runner.context().game.registerSave?.(sessionModule);
|
|
86
|
+
if (loaded !== null) {
|
|
87
|
+
if (Object.hasOwn(loaded.snapshot, "hostSession") && sessionModule.decode(loaded.snapshot["hostSession"]) === null) {
|
|
88
|
+
throw new Error("Invalid hosted player/retry state; save left untouched");
|
|
89
|
+
}
|
|
90
|
+
applyWorldSnapshot([sessionModule], loaded.snapshot);
|
|
91
|
+
}
|
|
38
92
|
let savedRevision = loaded?.revision ?? 0;
|
|
93
|
+
let savedVersion = runner.context().version();
|
|
94
|
+
let savedSimTick = runner.context().sim.tick();
|
|
39
95
|
let lastSaveAt = clock();
|
|
96
|
+
let writing = null;
|
|
97
|
+
let saveFailure = null;
|
|
40
98
|
function persist() {
|
|
41
|
-
|
|
42
|
-
|
|
99
|
+
const record = { snapshot: runner.state(), revision: runner.revision() };
|
|
100
|
+
const version = runner.context().version();
|
|
101
|
+
const simTick = runner.context().sim.tick();
|
|
102
|
+
const success = () => {
|
|
103
|
+
savedRevision = record.revision;
|
|
104
|
+
savedVersion = version;
|
|
105
|
+
savedSimTick = simTick;
|
|
106
|
+
saveFailure = null;
|
|
107
|
+
};
|
|
108
|
+
const write = () => store.save(record);
|
|
109
|
+
let pending;
|
|
110
|
+
try {
|
|
111
|
+
pending = writing === null ? write() : writing.catch(() => { }).then(write);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
saveFailure = error;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (pending === undefined) {
|
|
118
|
+
success();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const completion = pending.then(success, (error) => {
|
|
122
|
+
saveFailure = error;
|
|
123
|
+
throw error;
|
|
124
|
+
});
|
|
125
|
+
writing = completion;
|
|
126
|
+
void completion.catch(() => { }).finally(() => {
|
|
127
|
+
if (writing === completion)
|
|
128
|
+
writing = null;
|
|
129
|
+
});
|
|
130
|
+
return completion;
|
|
43
131
|
}
|
|
44
132
|
const pull = (sinceRevision) => {
|
|
45
|
-
if (sinceRevision === null || sinceRevision <= 0) {
|
|
133
|
+
if (sinceRevision === null || sinceRevision <= (loaded?.revision ?? 0) || sinceRevision > runner.revision()) {
|
|
46
134
|
return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
|
|
47
135
|
}
|
|
48
136
|
return { kind: "diff", diff: runner.diff(sinceRevision) };
|
|
49
137
|
};
|
|
50
138
|
return {
|
|
51
|
-
join
|
|
52
|
-
|
|
139
|
+
join(userId, isNew) {
|
|
140
|
+
runner.join(userId, knownPlayers.has(userId) ? false : isNew);
|
|
141
|
+
knownPlayers.add(userId);
|
|
142
|
+
runner.context().touch();
|
|
143
|
+
runner.commit();
|
|
144
|
+
},
|
|
145
|
+
hasPlayer: (userId) => knownPlayers.has(userId),
|
|
146
|
+
leave(userId) {
|
|
147
|
+
runner.leave(userId);
|
|
148
|
+
runner.commit();
|
|
149
|
+
},
|
|
53
150
|
input: (userId, frame) => runner.input(userId, frame),
|
|
54
|
-
command
|
|
151
|
+
command(userId, name, input, operationId) {
|
|
152
|
+
const envelope = unwrapOpEnvelope(input);
|
|
153
|
+
if (envelope !== null) {
|
|
154
|
+
operationId ??= envelope.opId;
|
|
155
|
+
input = envelope.input;
|
|
156
|
+
}
|
|
157
|
+
const receipts = operations.get(userId) ?? [];
|
|
158
|
+
const inputJson = JSON.stringify(input) ?? "null";
|
|
159
|
+
const receipt = operationId === undefined ? undefined : receipts.find((entry) => entry.id === operationId);
|
|
160
|
+
if (receipt !== undefined) {
|
|
161
|
+
if (receipt.command !== name || receipt.input !== inputJson)
|
|
162
|
+
return { status: "rejected", reason: "operation-conflict" };
|
|
163
|
+
if (receipt.outcome === "applied")
|
|
164
|
+
return { status: "applied", state: runner.context() };
|
|
165
|
+
if (receipt.outcome === "rejected")
|
|
166
|
+
return { status: "rejected", reason: receipt.reason };
|
|
167
|
+
return { status: "unknown-command" };
|
|
168
|
+
}
|
|
169
|
+
const before = runner.state();
|
|
170
|
+
let result;
|
|
171
|
+
try {
|
|
172
|
+
result = runner.command(userId, name, input);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
runner.context().restore(before);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
if (result.status !== "applied")
|
|
179
|
+
runner.context().restore(before);
|
|
180
|
+
if (operationId !== undefined) {
|
|
181
|
+
receipts.push({ id: operationId, command: name, input: inputJson, outcome: result.status,
|
|
182
|
+
...(result.status === "rejected" ? { reason: result.reason } : {}) });
|
|
183
|
+
operations.set(userId, receipts.slice(-historyLimit));
|
|
184
|
+
runner.context().touch();
|
|
185
|
+
}
|
|
186
|
+
runner.commit();
|
|
187
|
+
return result;
|
|
188
|
+
},
|
|
55
189
|
tick(dt) {
|
|
56
190
|
const revision = runner.tick(dt);
|
|
57
|
-
if (revision !== savedRevision) {
|
|
191
|
+
if (writing === null && (revision !== savedRevision || runner.context().version() !== savedVersion || runner.context().sim.tick() !== savedSimTick)) {
|
|
58
192
|
const at = clock();
|
|
59
193
|
if (saveIntervalMs <= 0 || at - lastSaveAt >= saveIntervalMs) {
|
|
60
194
|
lastSaveAt = at;
|
|
61
|
-
|
|
195
|
+
try {
|
|
196
|
+
persist();
|
|
197
|
+
}
|
|
198
|
+
catch { /* Exposed by persistenceError and retried by the next save. */ }
|
|
62
199
|
}
|
|
63
200
|
}
|
|
64
201
|
return revision;
|
|
@@ -70,6 +207,7 @@ export function createHostedWorldSession(options) {
|
|
|
70
207
|
revision: () => runner.revision(),
|
|
71
208
|
members: () => runner.members(),
|
|
72
209
|
save: persist,
|
|
210
|
+
persistenceError: () => saveFailure,
|
|
73
211
|
runner: () => runner,
|
|
74
212
|
};
|
|
75
213
|
}
|
|
@@ -81,7 +219,7 @@ export async function createHostedWorldSessionAsync(options) {
|
|
|
81
219
|
...sessionOptions,
|
|
82
220
|
store: {
|
|
83
221
|
load: () => loaded,
|
|
84
|
-
save: (record) =>
|
|
222
|
+
save: (record) => store.save(record),
|
|
85
223
|
},
|
|
86
224
|
});
|
|
87
225
|
return session;
|
|
@@ -13,8 +13,8 @@ export interface HostedWorldStore {
|
|
|
13
13
|
load(): Promise<HostedWorldRecord | null>;
|
|
14
14
|
save(record: HostedWorldRecord): Promise<void>;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/** Already-loaded store adapter: synchronous reads, with synchronous or asynchronous writes. */
|
|
17
17
|
export interface SyncHostedWorldStore {
|
|
18
18
|
load(): HostedWorldRecord | null;
|
|
19
|
-
save(record: HostedWorldRecord): void
|
|
19
|
+
save(record: HostedWorldRecord): void | Promise<void>;
|
|
20
20
|
}
|
|
@@ -10,7 +10,7 @@ export interface InputRecorderState {
|
|
|
10
10
|
}
|
|
11
11
|
/** Tick-indexed input log: record per tick, look up the frame in force at any tick. */
|
|
12
12
|
export interface InputRecorder {
|
|
13
|
-
/** Store
|
|
13
|
+
/** Store held state from `tick` onward and presses on that tick only. Same-tick recordings merge press identities while replacing continuous state. */
|
|
14
14
|
record(tick: number, frame: InputFrame): void;
|
|
15
15
|
/** The frame in force at `tick`: the latest recorded frame whose tick is at most `tick`, or `null` before the first. */
|
|
16
16
|
frameAt(tick: number): InputFrame | null;
|
|
@@ -2,6 +2,7 @@ function cloneFrame(frame) {
|
|
|
2
2
|
return {
|
|
3
3
|
held: [...frame.held],
|
|
4
4
|
pointer: frame.pointer === null ? null : { ...frame.pointer },
|
|
5
|
+
...(frame.presses === undefined ? {} : { presses: frame.presses.map(press => ({ ...press })) }),
|
|
5
6
|
...(frame.analog === undefined ? {} : { analog: frame.analog === null ? null : { ...frame.analog } }),
|
|
6
7
|
...(frame.tick === undefined ? {} : { tick: frame.tick }),
|
|
7
8
|
};
|
|
@@ -14,6 +15,7 @@ function cloneFrame(frame) {
|
|
|
14
15
|
*/
|
|
15
16
|
export function createInputRecorder() {
|
|
16
17
|
let frames = [];
|
|
18
|
+
const continuousFrames = new WeakMap();
|
|
17
19
|
function indexAtOrBefore(tick) {
|
|
18
20
|
let lo = 0;
|
|
19
21
|
let hi = frames.length - 1;
|
|
@@ -35,6 +37,13 @@ export function createInputRecorder() {
|
|
|
35
37
|
const entry = { tick, frame: cloneFrame(frame) };
|
|
36
38
|
const index = indexAtOrBefore(tick);
|
|
37
39
|
if (index >= 0 && frames[index].tick === tick) {
|
|
40
|
+
const previous = frames[index].frame.presses;
|
|
41
|
+
if (previous !== undefined) {
|
|
42
|
+
const presses = new Map(previous.map(press => [press.seq, press]));
|
|
43
|
+
for (const press of entry.frame.presses ?? [])
|
|
44
|
+
presses.set(press.seq, press);
|
|
45
|
+
entry.frame.presses = [...presses.values()].map(press => ({ ...press }));
|
|
46
|
+
}
|
|
38
47
|
frames[index] = entry;
|
|
39
48
|
return;
|
|
40
49
|
}
|
|
@@ -42,7 +51,18 @@ export function createInputRecorder() {
|
|
|
42
51
|
},
|
|
43
52
|
frameAt(tick) {
|
|
44
53
|
const index = indexAtOrBefore(tick);
|
|
45
|
-
|
|
54
|
+
if (index < 0)
|
|
55
|
+
return null;
|
|
56
|
+
const entry = frames[index];
|
|
57
|
+
if (entry.tick === tick || entry.frame.presses === undefined)
|
|
58
|
+
return entry.frame;
|
|
59
|
+
let heldFrame = continuousFrames.get(entry.frame);
|
|
60
|
+
if (heldFrame === undefined) {
|
|
61
|
+
const { presses: _presses, ...continuous } = entry.frame;
|
|
62
|
+
heldFrame = continuous;
|
|
63
|
+
continuousFrames.set(entry.frame, heldFrame);
|
|
64
|
+
}
|
|
65
|
+
return heldFrame;
|
|
46
66
|
},
|
|
47
67
|
frames: () => frames,
|
|
48
68
|
lastTick: () => (frames.length === 0 ? -1 : frames[frames.length - 1].tick),
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { PointerAxisState } from "../input/pointerAxis.js";
|
|
2
2
|
import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
|
|
3
3
|
import { type HapticChannels } from "../input/haptics.js";
|
|
4
|
-
/** One
|
|
4
|
+
/** One discrete press, identified across retransmitted live input frames. */
|
|
5
|
+
export interface InputPress {
|
|
6
|
+
action: string;
|
|
7
|
+
seq: number;
|
|
8
|
+
}
|
|
9
|
+
/** One client's serializable held state and discrete edges for a simulation tick. */
|
|
5
10
|
export interface InputFrame {
|
|
6
11
|
held: readonly string[];
|
|
7
12
|
pointer: PointerAxisState | null;
|
|
13
|
+
/** Discrete presses retained through release until the host accepts them. Each identity applies once, on its recorded simulation tick. */
|
|
14
|
+
presses?: readonly InputPress[];
|
|
8
15
|
/** 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. */
|
|
9
16
|
analog?: Readonly<Record<string, number>> | null;
|
|
10
17
|
/** 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. */
|
|
@@ -22,8 +29,13 @@ export interface InputSnapshot {
|
|
|
22
29
|
* and the shell mixes them by priority onto that user's pad. Local only — never in a snapshot or on the wire.
|
|
23
30
|
*/
|
|
24
31
|
haptics(userId: string): HapticChannels;
|
|
25
|
-
/** Replaces
|
|
26
|
-
publish(held: readonly string[]
|
|
32
|
+
/** Replaces held state without notifying subscribers. Supplying `edges` buffers short presses until `beginStep`; `reset` discards the current owner's pending edges. Without `edges`, legacy publication-based edge detection is preserved. */
|
|
33
|
+
publish(held: readonly string[], edges?: {
|
|
34
|
+
pressed?: readonly string[];
|
|
35
|
+
reset?: boolean;
|
|
36
|
+
}): void;
|
|
37
|
+
/** Sample buffered input once before a simulation step. Zero-step render frames retain presses; subsequent steps retire the sampled edges. */
|
|
38
|
+
beginStep(): void;
|
|
27
39
|
/** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
|
|
28
40
|
publishPointer(state: PointerAxisState | null): void;
|
|
29
41
|
/** Replaces the analog per-action magnitudes for this frame (#1370) — the virtual joystick's continuous vector, split into 0..1 values per movement action. `null` clears back to digital-only. Same no-notify contract as `publish`. */
|
|
@@ -31,7 +43,7 @@ export interface InputSnapshot {
|
|
|
31
43
|
isDown(action: string): boolean;
|
|
32
44
|
/** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
|
|
33
45
|
held(): readonly string[];
|
|
34
|
-
/** True
|
|
46
|
+
/** True on the sampled simulation step containing a press; legacy publishers use the last two held sets. */
|
|
35
47
|
justPressed(action: string): boolean;
|
|
36
48
|
/** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
|
|
37
49
|
justReleased(action: string): boolean;
|
|
@@ -6,6 +6,11 @@ export function createInputSnapshot() {
|
|
|
6
6
|
let heldList = [];
|
|
7
7
|
let pointerState = null;
|
|
8
8
|
let analogValues = null;
|
|
9
|
+
let buffered = false;
|
|
10
|
+
let stepHeld = new Set();
|
|
11
|
+
const pendingPressed = new Set();
|
|
12
|
+
let stepPressed = new Set();
|
|
13
|
+
let stepReleased = new Set();
|
|
9
14
|
const haptics = new Map();
|
|
10
15
|
const value = (action) => {
|
|
11
16
|
const analog = analogValues?.[action];
|
|
@@ -23,10 +28,33 @@ export function createInputSnapshot() {
|
|
|
23
28
|
}
|
|
24
29
|
return channels;
|
|
25
30
|
},
|
|
26
|
-
publish(held) {
|
|
31
|
+
publish(held, edges) {
|
|
27
32
|
previousHeldSet = heldSet;
|
|
28
33
|
heldList = Object.freeze([...held]);
|
|
29
34
|
heldSet = new Set(held);
|
|
35
|
+
if (edges !== undefined) {
|
|
36
|
+
buffered = true;
|
|
37
|
+
if (edges.reset) {
|
|
38
|
+
pendingPressed.clear();
|
|
39
|
+
stepPressed.clear();
|
|
40
|
+
stepReleased.clear();
|
|
41
|
+
stepHeld = new Set(held);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
for (const action of edges.pressed ?? [])
|
|
45
|
+
pendingPressed.add(action);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
beginStep() {
|
|
50
|
+
buffered = true;
|
|
51
|
+
stepPressed = new Set(pendingPressed);
|
|
52
|
+
pendingPressed.clear();
|
|
53
|
+
for (const action of heldSet)
|
|
54
|
+
if (!stepHeld.has(action))
|
|
55
|
+
stepPressed.add(action);
|
|
56
|
+
stepReleased = new Set([...stepHeld].filter(action => !heldSet.has(action)));
|
|
57
|
+
stepHeld = new Set(heldSet);
|
|
30
58
|
},
|
|
31
59
|
publishPointer(state) {
|
|
32
60
|
pointerState = state === null ? null : Object.freeze({ ...state });
|
|
@@ -40,7 +68,7 @@ export function createInputSnapshot() {
|
|
|
40
68
|
value,
|
|
41
69
|
analog: () => analogValues,
|
|
42
70
|
axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges, value),
|
|
43
|
-
justPressed: (action) => heldSet.has(action) && !previousHeldSet.has(action),
|
|
44
|
-
justReleased: (action) => !heldSet.has(action) && previousHeldSet.has(action),
|
|
71
|
+
justPressed: (action) => buffered ? stepPressed.has(action) : heldSet.has(action) && !previousHeldSet.has(action),
|
|
72
|
+
justReleased: (action) => buffered ? stepReleased.has(action) : !heldSet.has(action) && previousHeldSet.has(action),
|
|
45
73
|
};
|
|
46
74
|
}
|
|
@@ -2,7 +2,7 @@ import { type SaveBackend, type SaveStatus, type SaveTimers } from "../game/save
|
|
|
2
2
|
import type { WorldSnapshot } from "./worldSnapshot.js";
|
|
3
3
|
/**
|
|
4
4
|
* The narrow slice of a live `GameContext` a runtime save reads and writes — a
|
|
5
|
-
* `GameContext` satisfies it directly (`ctx.
|
|
5
|
+
* `GameContext` satisfies it directly (`ctx.state`/`ctx.restore`/`ctx.subscribe`).
|
|
6
6
|
* Depending on this instead of the full context keeps the save controller a deep,
|
|
7
7
|
* decoupled module: it captures and restores the *whole* opted-in world without
|
|
8
8
|
* knowing any subsystem.
|
|
@@ -10,6 +10,10 @@ import type { WorldSnapshot } from "./worldSnapshot.js";
|
|
|
10
10
|
export interface RuntimeSaveTarget {
|
|
11
11
|
snapshot(): WorldSnapshot;
|
|
12
12
|
hydrate(snapshot: WorldSnapshot): void;
|
|
13
|
+
/** Complete authoritative capture when the target distinguishes persistence from replication. */
|
|
14
|
+
state?(): WorldSnapshot;
|
|
15
|
+
/** Authoritative inverse of `state`; used together with it instead of replication hydration. */
|
|
16
|
+
restore?(state: WorldSnapshot): void;
|
|
13
17
|
subscribe(listener: () => void): () => void;
|
|
14
18
|
}
|
|
15
19
|
/** `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
|
|
@@ -39,8 +43,8 @@ export interface RuntimeSaveConfig {
|
|
|
39
43
|
export type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target">;
|
|
40
44
|
/**
|
|
41
45
|
* Whole-world save/load bound to a live world and a pluggable backend. `save()`
|
|
42
|
-
* captures `target.
|
|
43
|
-
* `target.
|
|
46
|
+
* captures `target.state()` and writes it; `load()` reads it back and
|
|
47
|
+
* calls `target.restore()`. Older targets use `snapshot()`/`hydrate()`. In `autosave` mode it also writes on a
|
|
44
48
|
* trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud
|
|
45
49
|
* (backend swap) all come for free from the underlying save store.
|
|
46
50
|
*/
|
|
@@ -51,13 +55,13 @@ export interface RuntimeSave {
|
|
|
51
55
|
save(): Promise<void>;
|
|
52
56
|
/** Semantic alias for `save()` — call it from a save point, a quest-complete or area-enter handler. */
|
|
53
57
|
checkpoint(): Promise<void>;
|
|
54
|
-
/** Load the active slot and hydrate the whole world; resolves `true` when a real save was applied, `false` for an empty slot. */
|
|
58
|
+
/** Load the active slot and hydrate the whole world; resolves `true` when a real save was applied, `false` for an empty slot or failed payload read. Failed reads never restore cached state. */
|
|
55
59
|
load(): Promise<boolean>;
|
|
56
|
-
/** Whether the active slot holds a saved world — gate a title-screen "Continue" on it. */
|
|
60
|
+
/** Whether the active slot holds a saved world — gate a title-screen "Continue" on it. Failed payload reads resolve false and report error status. */
|
|
57
61
|
hasSave(): Promise<boolean>;
|
|
58
62
|
/** Delete the active slot's save. */
|
|
59
63
|
clear(): Promise<void>;
|
|
60
|
-
/** Switch slot and load it, hydrating the world when that slot has a save. */
|
|
64
|
+
/** Switch slot and load it, hydrating the world when that slot has a save. Failed payload reads resolve false without restoring cached state. */
|
|
61
65
|
switchSlot(slot: string): Promise<boolean>;
|
|
62
66
|
/** The slots this game has written — back a load/save menu. */
|
|
63
67
|
slots(): Promise<string[]>;
|
|
@@ -48,7 +48,7 @@ export function createRuntimeSave(config) {
|
|
|
48
48
|
}
|
|
49
49
|
function persist() {
|
|
50
50
|
clearTimer();
|
|
51
|
-
store.set(config.target.snapshot());
|
|
51
|
+
store.set(config.target.state?.() ?? config.target.snapshot());
|
|
52
52
|
return store.save();
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
@@ -77,7 +77,10 @@ export function createRuntimeSave(config) {
|
|
|
77
77
|
return false;
|
|
78
78
|
restoring = true;
|
|
79
79
|
try {
|
|
80
|
-
config.target.
|
|
80
|
+
if (config.target.restore !== undefined)
|
|
81
|
+
config.target.restore(snapshot);
|
|
82
|
+
else
|
|
83
|
+
config.target.hydrate(snapshot);
|
|
81
84
|
}
|
|
82
85
|
finally {
|
|
83
86
|
restoring = false;
|
|
@@ -91,15 +94,18 @@ export function createRuntimeSave(config) {
|
|
|
91
94
|
save,
|
|
92
95
|
checkpoint: save,
|
|
93
96
|
async load() {
|
|
94
|
-
|
|
97
|
+
const snapshot = await store.load();
|
|
98
|
+
return store.status() !== "error" && applyLoaded(snapshot);
|
|
95
99
|
},
|
|
96
100
|
async hasSave() {
|
|
97
|
-
|
|
101
|
+
const snapshot = await store.load();
|
|
102
|
+
return store.status() !== "error" && hasContent(snapshot);
|
|
98
103
|
},
|
|
99
104
|
clear: () => store.clear(),
|
|
100
105
|
async switchSlot(slot) {
|
|
101
106
|
clearTimer();
|
|
102
|
-
|
|
107
|
+
const snapshot = await store.switchSlot(slot);
|
|
108
|
+
return store.status() !== "error" && applyLoaded(snapshot);
|
|
103
109
|
},
|
|
104
110
|
slots: () => store.slots(),
|
|
105
111
|
subscribe: (listener) => store.subscribe(listener),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityStore } from "../scene/entityStore.js";
|
|
2
|
+
import type { SimClock } from "../time/simClock.js";
|
|
2
3
|
import { type PoseHistory, type RenderPose } from "./poseInterpolation.js";
|
|
3
4
|
import { type SimAdvanceResult, type SimLoop, type SimulationConfig } from "./simLoop.js";
|
|
4
5
|
/** Where a registered stage runs inside one simulation step. */
|
|
@@ -7,7 +8,8 @@ export type SimStagePhase = "beforeMovement" | "afterMovement" | "afterTick";
|
|
|
7
8
|
export interface SimStage {
|
|
8
9
|
id: string;
|
|
9
10
|
phase: SimStagePhase;
|
|
10
|
-
|
|
11
|
+
/** dt is real seconds; gameDt is the scaled authoritative game-seconds for this step. */
|
|
12
|
+
run(dt: number, tick: number, gameDt: number): void;
|
|
11
13
|
}
|
|
12
14
|
/** Serializable `ctx.sim` state: loop position plus the interpolation pose history. */
|
|
13
15
|
export interface SimContextState {
|
|
@@ -21,8 +23,10 @@ export interface SimContextState {
|
|
|
21
23
|
*/
|
|
22
24
|
export interface SimContext {
|
|
23
25
|
readonly loop: SimLoop;
|
|
24
|
-
/** Advance
|
|
25
|
-
advance(realDt: number, body: (dt: number, tick: number) => void
|
|
26
|
+
/** Advance simulation and its game clock once per step. The callback receives real dt, tick and scaled game dt. Prediction can opt out of clock advancement. */
|
|
27
|
+
advance(realDt: number, body: (dt: number, tick: number, gameDt: number) => void, options?: {
|
|
28
|
+
advanceTime?: boolean;
|
|
29
|
+
}): SimAdvanceResult;
|
|
26
30
|
tick(): number;
|
|
27
31
|
/** Interpolation fraction for the current frame, 1 on a variable loop. */
|
|
28
32
|
alpha(): number;
|
|
@@ -42,4 +46,5 @@ export interface SimContext {
|
|
|
42
46
|
export declare function createSimContext(options: {
|
|
43
47
|
config?: SimulationConfig;
|
|
44
48
|
entities: EntityStore;
|
|
49
|
+
time?: Pick<SimClock, "advance">;
|
|
45
50
|
}): SimContext;
|
|
@@ -7,9 +7,10 @@ export function createSimContext(options) {
|
|
|
7
7
|
const poses = createPoseHistory({ snapDistance: loop.config().snapDistance });
|
|
8
8
|
const stages = new Map();
|
|
9
9
|
let advancing = false;
|
|
10
|
+
let currentGameDt = 0;
|
|
10
11
|
return {
|
|
11
12
|
loop,
|
|
12
|
-
advance(realDt, body) {
|
|
13
|
+
advance(realDt, body, stepOptions) {
|
|
13
14
|
if (advancing)
|
|
14
15
|
throw new Error("ctx.sim.advance is not reentrant");
|
|
15
16
|
advancing = true;
|
|
@@ -17,11 +18,14 @@ export function createSimContext(options) {
|
|
|
17
18
|
return loop.advance(realDt, (dt, tick) => {
|
|
18
19
|
if (loop.isFixed())
|
|
19
20
|
poses.beginStep(entities);
|
|
20
|
-
|
|
21
|
+
const gameDt = stepOptions?.advanceTime === false ? 0 : options.time?.advance(dt) ?? dt;
|
|
22
|
+
currentGameDt = gameDt;
|
|
23
|
+
body(dt, tick, gameDt);
|
|
21
24
|
});
|
|
22
25
|
}
|
|
23
26
|
finally {
|
|
24
27
|
advancing = false;
|
|
28
|
+
currentGameDt = 0;
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
tick: () => loop.tick(),
|
|
@@ -49,7 +53,7 @@ export function createSimContext(options) {
|
|
|
49
53
|
return;
|
|
50
54
|
const tick = loop.tick();
|
|
51
55
|
for (const stage of list)
|
|
52
|
-
stage.run(dt, tick);
|
|
56
|
+
stage.run(dt, tick, advancing ? currentGameDt : dt);
|
|
53
57
|
},
|
|
54
58
|
retune(config) {
|
|
55
59
|
loop.retune(config);
|
|
@@ -47,6 +47,8 @@ export function createContextSimSnapshot(ctx) {
|
|
|
47
47
|
capture: () => structuredClone(ctx.snapshot()),
|
|
48
48
|
restore: (data) => ctx.hydrate(structuredClone(data)),
|
|
49
49
|
});
|
|
50
|
+
registry.register({ id: "projectiles", capture: ctx.scene.entity.projectileState, restore: ctx.scene.entity.restoreProjectiles });
|
|
51
|
+
registry.register({ id: "environment", capture: ctx.environment.snapshot, restore: ctx.environment.restore });
|
|
50
52
|
registry.register({ id: "clock", capture: () => ctx.time.snapshot(), restore: (data) => ctx.time.hydrate(data) });
|
|
51
53
|
registry.register({ id: "sim", capture: () => ctx.sim.snapshot(), restore: (data) => ctx.sim.restore(data) });
|
|
52
54
|
if (rngStateOf(ctx.rng) !== null) {
|
|
@@ -35,6 +35,8 @@ export interface WorldDiff {
|
|
|
35
35
|
*/
|
|
36
36
|
export interface WorldReplicatorOptions {
|
|
37
37
|
worldVersion?: () => number;
|
|
38
|
+
/** Cursor loaded with a persisted baseline; new commits continue above it. Default `0`. */
|
|
39
|
+
initialRevision?: number;
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* Turns successive full {@link WorldSnapshot}s into per-client {@link WorldDiff}s. Each `commit()` re-reads the
|
|
@@ -10,7 +10,7 @@ const CORE_KEYS = new Set(["entities", "stats", "store"]);
|
|
|
10
10
|
export function createWorldReplicator(takeSnapshot, options = {}) {
|
|
11
11
|
const worldVersion = options.worldVersion;
|
|
12
12
|
let committedVersion;
|
|
13
|
-
let revision = 0;
|
|
13
|
+
let revision = options.initialRevision ?? 0;
|
|
14
14
|
const entities = new Map();
|
|
15
15
|
const removedEntities = new Map();
|
|
16
16
|
const stats = new Map();
|