@jgengine/core 0.18.1 → 0.21.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 +504 -7
- package/dist/ai/decisionGraph.d.ts +50 -4
- package/dist/ai/decisionGraph.js +159 -23
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/ai/groupAssist.d.ts +10 -0
- package/dist/ai/groupAssist.js +11 -0
- package/dist/ai/pursuit.d.ts +1 -1
- package/dist/ai/pursuit.js +1 -1
- package/dist/ai/threat.d.ts +8 -0
- package/dist/ai/threat.js +10 -0
- package/dist/anim/animGraph.d.ts +43 -1
- package/dist/anim/animGraph.js +326 -10
- 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/locomotionGraph.d.ts +18 -1
- package/dist/anim/locomotionGraph.js +42 -3
- package/dist/audio/audioFalloff.d.ts +6 -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/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +20 -0
- package/dist/combat/abilityKit.js +41 -4
- package/dist/combat/death.d.ts +11 -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/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/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/devtools/warnOnce.d.ts +8 -0
- package/dist/devtools/warnOnce.js +18 -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 +119 -18
- package/dist/editor/environment.js +8 -0
- package/dist/editor/index.d.ts +2 -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/types.d.ts +15 -0
- package/dist/faction/reputation.d.ts +6 -0
- package/dist/faction/reputation.js +11 -0
- package/dist/game/cameraConfig.d.ts +54 -2
- 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 +11 -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/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +16 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +134 -35
- 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/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/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/input/actionBindings.d.ts +15 -1
- package/dist/input/actionBindings.js +14 -3
- package/dist/input/actionContexts.d.ts +25 -1
- package/dist/input/actionContexts.js +82 -25
- 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 +36 -2
- package/dist/input/gamepadModel.js +69 -30
- 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/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/affix.d.ts +6 -0
- package/dist/item/affix.js +6 -0
- 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 +444 -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/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 +63 -1
- package/dist/movement/movementModel.js +136 -26
- package/dist/movement/movementProbe.d.ts +51 -0
- package/dist/movement/movementProbe.js +119 -0
- package/dist/movement/playerMovement.d.ts +48 -2
- package/dist/movement/playerMovement.js +493 -87
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +11 -1
- package/dist/movement/solidObstacles.js +48 -3
- 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 +10 -10
- package/dist/nav/navFromEnvironment.d.ts +9 -3
- package/dist/nav/navFromEnvironment.js +18 -12
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/navMesh.d.ts +43 -2
- package/dist/nav/navMesh.js +534 -128
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/aircraftDynamics.d.ts +390 -0
- package/dist/physics/aircraftDynamics.js +704 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- 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 +21 -4
- package/dist/physics/flightDynamics.js +28 -5
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/handlingProbe.d.ts +249 -0
- package/dist/physics/handlingProbe.js +606 -0
- package/dist/physics/kinematicVehicle.d.ts +40 -5
- package/dist/physics/kinematicVehicle.js +17 -2
- package/dist/physics/physicsBackend.d.ts +12 -0
- package/dist/physics/physicsBackendConformance.js +34 -0
- package/dist/physics/physicsWorldBackend.d.ts +1 -0
- package/dist/physics/physicsWorldBackend.js +52 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleBackendLink.d.ts +49 -0
- package/dist/physics/vehicleBackendLink.js +102 -0
- package/dist/physics/vehicleDynamics.d.ts +375 -0
- package/dist/physics/vehicleDynamics.js +737 -0
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/physics/worldColliders.d.ts +1 -1
- package/dist/physics/worldColliders.js +35 -3
- package/dist/puzzle/cellGrid.d.ts +15 -9
- package/dist/puzzle/cellGrid.js +13 -9
- package/dist/render/lookPreset.d.ts +9 -2
- package/dist/render/lookPreset.js +29 -2
- package/dist/render/postProcessing.d.ts +30 -0
- package/dist/render/postProcessing.js +1 -1
- package/dist/runtime/cameraDirector.d.ts +54 -3
- package/dist/runtime/cameraDirector.js +187 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/commandInput.d.ts +37 -0
- package/dist/runtime/commandInput.js +58 -0
- package/dist/runtime/commandRunner.d.ts +27 -0
- package/dist/runtime/commandRunner.js +31 -3
- package/dist/runtime/context/combat.d.ts +12 -4
- package/dist/runtime/context/combat.js +179 -11
- package/dist/runtime/context/deathLoot.d.ts +11 -8
- package/dist/runtime/context/deathLoot.js +17 -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 +86 -13
- package/dist/runtime/gameContextTypes.d.ts +33 -3
- package/dist/runtime/gameRuntime.d.ts +3 -1
- package/dist/runtime/gameRuntime.js +4 -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 +22 -4
- package/dist/runtime/inputSnapshot.js +41 -3
- package/dist/runtime/localPlayers.d.ts +73 -0
- package/dist/runtime/localPlayers.js +119 -0
- 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/transport.d.ts +9 -0
- package/dist/runtime/transport.js +7 -1
- 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 +38 -7
- package/dist/scene/behaviorRuntime.js +293 -30
- package/dist/scene/behaviors.d.ts +35 -2
- 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/sceneKinds.d.ts +9 -0
- 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 +41 -3
- package/dist/scene/vehicleSeat.js +94 -3
- 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 +11 -1
- package/dist/sensor/perception.js +57 -10
- 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 +2 -0
- package/dist/settings/graphicsProfile.js +12 -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/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +10 -3
- package/dist/stats/eventMeter.js +6 -3
- 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/feedbackMixer.d.ts +61 -0
- package/dist/vfx/feedbackMixer.js +106 -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/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/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/authoredSolids.d.ts +46 -0
- package/dist/world/authoredSolids.js +75 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingGenerator.js +2 -2
- package/dist/world/buildingKit.d.ts +11 -4
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/buildings.d.ts +34 -2
- package/dist/world/buildings.js +11 -1
- package/dist/world/cityKind.d.ts +7 -0
- package/dist/world/cityKind.js +52 -1
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +32 -5
- package/dist/world/features.js +6 -0
- package/dist/world/footprintGrid.d.ts +62 -0
- package/dist/world/footprintGrid.js +217 -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/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +9 -1
- package/dist/world/terraform.js +53 -18
- package/dist/world/walls.d.ts +7 -0
- package/dist/world/walls.js +7 -0
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +95 -0
- package/dist/world/worldSolids.js +351 -0
- package/dist/world.d.ts +6 -6
- package/dist/world.js +5 -5
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-combat/SKILL.md +13 -2
- package/skills/jgengine-combat/api.md +150 -103
- package/skills/jgengine-combat/capabilities.md +29 -1
- 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/recipes/weapon-feel.md +50 -0
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +583 -463
- package/skills/jgengine-gameplay/capabilities.md +92 -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 +123 -0
- package/skills/jgengine-world/SKILL.md +25 -4
- package/skills/jgengine-world/api.md +1045 -655
- package/skills/jgengine-world/capabilities.md +246 -9
- package/skills/jgengine-world/recipes/character-animation.md +62 -0
- package/skills/jgengine-world/recipes/controls.md +48 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +55 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
- package/skills/jgengine-world/reference.md +180 -4
- package/skills/jgengine-world/visibility.md +139 -0
|
@@ -24,29 +24,142 @@ export function createHostedGameRunner(options) {
|
|
|
24
24
|
const loop = definition.loop ?? {};
|
|
25
25
|
const replicator = createWorldReplicator(() => ctx.snapshot(), {
|
|
26
26
|
worldVersion: () => ctx.replicationVersion(),
|
|
27
|
+
initialRevision: options.revision ?? 0,
|
|
27
28
|
});
|
|
28
29
|
const members = new Map();
|
|
29
30
|
const inputs = new Map();
|
|
30
31
|
const latestInputs = new Map();
|
|
31
|
-
const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics });
|
|
32
|
+
const movementTuning = { ...resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics }), authoritativeStep: true };
|
|
32
33
|
const inputSeq = new Map();
|
|
34
|
+
const inputPressSeq = new Map();
|
|
35
|
+
const departed = new Map();
|
|
36
|
+
const maxDepartedUsers = options.inputRetention?.maxDepartedUsers ?? 256;
|
|
37
|
+
const departedTtlMs = options.inputRetention?.departedTtlMs ?? 300_000;
|
|
38
|
+
const inputNowMs = options.inputRetention?.nowMs ?? Date.now;
|
|
39
|
+
if (!Number.isInteger(maxDepartedUsers) || maxDepartedUsers < 0 || !Number.isFinite(departedTtlMs) || departedTtlMs < 0) {
|
|
40
|
+
throw new RangeError("inputRetention requires a nonnegative integer user limit and finite nonnegative TTL");
|
|
41
|
+
}
|
|
42
|
+
let retentionTime = -Infinity;
|
|
43
|
+
const retentionNow = () => {
|
|
44
|
+
const time = inputNowMs();
|
|
45
|
+
if (!Number.isFinite(time))
|
|
46
|
+
throw new RangeError("inputRetention.nowMs must return finite milliseconds");
|
|
47
|
+
return retentionTime = Math.max(retentionTime, time);
|
|
48
|
+
};
|
|
49
|
+
const expireDeparted = () => {
|
|
50
|
+
const time = retentionNow();
|
|
51
|
+
// Monotone leave times keep insertion order equal to expiry order, even if
|
|
52
|
+
// the wall clock reverses. Live ingress inspects only the first tombstone.
|
|
53
|
+
while (departed.size > 0) {
|
|
54
|
+
const [userId, expiresAt] = departed.entries().next().value;
|
|
55
|
+
if (expiresAt > time && departed.size <= maxDepartedUsers)
|
|
56
|
+
break;
|
|
57
|
+
departed.delete(userId);
|
|
58
|
+
inputSeq.delete(userId);
|
|
59
|
+
inputPressSeq.delete(userId);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const compactRecorder = (recorder, tick) => {
|
|
63
|
+
const continuous = recorder.frameAt(tick);
|
|
64
|
+
const frames = recorder.frames().filter(entry => entry.tick > tick);
|
|
65
|
+
if (continuous !== null) {
|
|
66
|
+
const { presses: _consumed, ...held } = continuous;
|
|
67
|
+
frames.unshift({ tick, frame: held });
|
|
68
|
+
}
|
|
69
|
+
recorder.restore({ frames });
|
|
70
|
+
};
|
|
33
71
|
let hostTick = 0;
|
|
72
|
+
const inputModule = {
|
|
73
|
+
key: "hostInput",
|
|
74
|
+
snapshot() {
|
|
75
|
+
const tick = ctx.sim.tick();
|
|
76
|
+
const saved = [];
|
|
77
|
+
for (const userId of members.keys()) {
|
|
78
|
+
const recorder = inputs.get(userId);
|
|
79
|
+
const latest = latestInputs.get(userId);
|
|
80
|
+
if (recorder === undefined || latest === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const continuous = recorder.frameAt(tick);
|
|
83
|
+
// Keep continuous intent and unconsumed future ticks, never elapsed history.
|
|
84
|
+
// Live input coalesces onto the next tick; explicit replay may stage future ticks.
|
|
85
|
+
const frames = recorder.frames().filter(entry => entry.tick > tick);
|
|
86
|
+
if (continuous !== null) {
|
|
87
|
+
const { presses: _consumed, ...held } = continuous;
|
|
88
|
+
frames.unshift({ tick, frame: held });
|
|
89
|
+
}
|
|
90
|
+
const seq = inputSeq.get(userId);
|
|
91
|
+
const pressSeq = inputPressSeq.get(userId);
|
|
92
|
+
saved.push({ userId, latest, recorder: { frames }, ...(seq === undefined ? {} : { seq }), ...(pressSeq === undefined ? {} : { pressSeq }) });
|
|
93
|
+
}
|
|
94
|
+
return saved;
|
|
95
|
+
},
|
|
96
|
+
hydrate(saved) {
|
|
97
|
+
inputs.clear();
|
|
98
|
+
latestInputs.clear();
|
|
99
|
+
inputSeq.clear();
|
|
100
|
+
inputPressSeq.clear();
|
|
101
|
+
departed.clear();
|
|
102
|
+
for (const input of saved) {
|
|
103
|
+
const recorder = createInputRecorder();
|
|
104
|
+
recorder.restore(input.recorder);
|
|
105
|
+
inputs.set(input.userId, recorder);
|
|
106
|
+
latestInputs.set(input.userId, input.latest);
|
|
107
|
+
if (input.seq !== undefined)
|
|
108
|
+
inputSeq.set(input.userId, input.seq);
|
|
109
|
+
if (input.pressSeq !== undefined)
|
|
110
|
+
inputPressSeq.set(input.userId, input.pressSeq);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
decode(raw) {
|
|
114
|
+
const frame = (value) => {
|
|
115
|
+
if (value === null || typeof value !== "object" || !("held" in value) || !Array.isArray(value.held) || !value.held.every(action => typeof action === "string"))
|
|
116
|
+
return false;
|
|
117
|
+
if (!("pointer" in value))
|
|
118
|
+
return false;
|
|
119
|
+
const pointer = value.pointer;
|
|
120
|
+
if (pointer !== null && (typeof pointer !== "object" || !("x" in pointer) || !("y" in pointer) || !("active" in pointer) || !Number.isFinite(pointer.x) || !Number.isFinite(pointer.y) || typeof pointer.active !== "boolean"))
|
|
121
|
+
return false;
|
|
122
|
+
if ("tick" in value && value.tick !== undefined && !Number.isFinite(value.tick))
|
|
123
|
+
return false;
|
|
124
|
+
if ("presses" in value && value.presses !== undefined && (!Array.isArray(value.presses) || !value.presses.every(press => press !== null && typeof press === "object" && typeof press.action === "string" && Number.isFinite(press.seq))))
|
|
125
|
+
return false;
|
|
126
|
+
if ("analog" in value && value.analog !== undefined && value.analog !== null && (typeof value.analog !== "object" || Array.isArray(value.analog) || !Object.values(value.analog).every(Number.isFinite)))
|
|
127
|
+
return false;
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
if (!Array.isArray(raw) || !raw.every(input => input !== null && typeof input === "object" && typeof input.userId === "string" &&
|
|
131
|
+
(input.seq === undefined || Number.isFinite(input.seq)) && (input.pressSeq === undefined || Number.isFinite(input.pressSeq)) && frame(input.latest) &&
|
|
132
|
+
input.recorder !== null && typeof input.recorder === "object" && Array.isArray(input.recorder.frames) &&
|
|
133
|
+
input.recorder.frames.every((entry) => entry !== null && typeof entry === "object" && Number.isFinite(entry.tick) && frame(entry.frame))))
|
|
134
|
+
return null;
|
|
135
|
+
if (new Set(raw.map(input => input.userId)).size !== raw.length)
|
|
136
|
+
return null;
|
|
137
|
+
return raw;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
ctx.game.registerSave?.(inputModule);
|
|
34
141
|
loop.onInit?.(ctx);
|
|
35
142
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
36
143
|
if (restore !== undefined)
|
|
37
|
-
ctx.
|
|
144
|
+
ctx.restore(restore);
|
|
38
145
|
return {
|
|
39
146
|
join(userId, isNew) {
|
|
147
|
+
expireDeparted();
|
|
40
148
|
if (members.has(userId))
|
|
41
149
|
return;
|
|
150
|
+
departed.delete(userId);
|
|
42
151
|
const player = { userId, isNew };
|
|
43
152
|
members.set(userId, player);
|
|
153
|
+
ctx.player.possession.own(userId, userId);
|
|
44
154
|
ctx.game.players?.join(userId, isNew);
|
|
45
155
|
loop.onNewPlayer?.(ctx, player);
|
|
46
156
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
47
157
|
},
|
|
48
158
|
resume(userId) {
|
|
159
|
+
expireDeparted();
|
|
160
|
+
departed.delete(userId);
|
|
49
161
|
members.set(userId, { userId, isNew: false });
|
|
162
|
+
ctx.player.possession.own(userId, userId);
|
|
50
163
|
ctx.game.players?.join(userId, false);
|
|
51
164
|
},
|
|
52
165
|
leave(userId) {
|
|
@@ -56,12 +169,22 @@ export function createHostedGameRunner(options) {
|
|
|
56
169
|
members.delete(userId);
|
|
57
170
|
inputs.delete(userId);
|
|
58
171
|
latestInputs.delete(userId);
|
|
59
|
-
|
|
172
|
+
// Retain only bounded, recent admission marks; departed ingress is ignored.
|
|
173
|
+
if (inputSeq.has(userId) || inputPressSeq.has(userId)) {
|
|
174
|
+
departed.delete(userId);
|
|
175
|
+
departed.set(userId, retentionNow() + departedTtlMs);
|
|
176
|
+
}
|
|
177
|
+
expireDeparted();
|
|
60
178
|
loop.onPlayerLeave?.(ctx, player);
|
|
61
179
|
ctx.game.players?.leave(userId);
|
|
62
180
|
},
|
|
63
181
|
input(userId, frame) {
|
|
182
|
+
expireDeparted();
|
|
183
|
+
if (!members.has(userId))
|
|
184
|
+
return;
|
|
64
185
|
const seq = frame.seq;
|
|
186
|
+
if ((seq !== undefined && !Number.isFinite(seq)) || (frame.tick !== undefined && !Number.isFinite(frame.tick)))
|
|
187
|
+
return;
|
|
65
188
|
if (seq !== undefined) {
|
|
66
189
|
const lastSeq = inputSeq.get(userId);
|
|
67
190
|
if (lastSeq !== undefined && seq <= lastSeq)
|
|
@@ -73,8 +196,26 @@ export function createHostedGameRunner(options) {
|
|
|
73
196
|
recorder = createInputRecorder();
|
|
74
197
|
inputs.set(userId, recorder);
|
|
75
198
|
}
|
|
76
|
-
|
|
77
|
-
|
|
199
|
+
let admitted = frame;
|
|
200
|
+
if (frame.presses !== undefined) {
|
|
201
|
+
let lastPress = inputPressSeq.get(userId) ?? -Infinity;
|
|
202
|
+
const presses = [...frame.presses].sort((a, b) => a.seq - b.seq).filter(press => {
|
|
203
|
+
if (!Number.isFinite(press.seq) || press.seq <= lastPress)
|
|
204
|
+
return false;
|
|
205
|
+
lastPress = press.seq;
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
if (presses.length > 0)
|
|
209
|
+
inputPressSeq.set(userId, lastPress);
|
|
210
|
+
admitted = { ...frame, presses };
|
|
211
|
+
}
|
|
212
|
+
recorder.record(frame.tick ?? ctx.sim.tick() + 1, admitted);
|
|
213
|
+
compactRecorder(recorder, ctx.sim.tick());
|
|
214
|
+
latestInputs.set(userId, admitted);
|
|
215
|
+
},
|
|
216
|
+
inputStats() {
|
|
217
|
+
expireDeparted();
|
|
218
|
+
return { members: members.size, recorders: inputs.size, recordedFrames: [...inputs.values()].reduce((count, recorder) => count + recorder.frames().length, 0), latestInputs: latestInputs.size, wireSequences: inputSeq.size, pressSequences: inputPressSeq.size, departedUsers: departed.size };
|
|
78
219
|
},
|
|
79
220
|
heldInput(userId) {
|
|
80
221
|
return latestInputs.get(userId) ?? null;
|
|
@@ -83,8 +224,9 @@ export function createHostedGameRunner(options) {
|
|
|
83
224
|
return ctx.game.commands.runAs(userId, name, input);
|
|
84
225
|
},
|
|
85
226
|
tick(dt) {
|
|
227
|
+
expireDeparted();
|
|
86
228
|
hostTick += 1;
|
|
87
|
-
ctx.sim.advance(dt, (stepDt, tick) => {
|
|
229
|
+
ctx.sim.advance(dt, (stepDt, tick, gameDt) => {
|
|
88
230
|
for (const [userId, recorder] of inputs) {
|
|
89
231
|
const frame = recorder.frameAt(tick);
|
|
90
232
|
if (frame !== null)
|
|
@@ -93,13 +235,15 @@ export function createHostedGameRunner(options) {
|
|
|
93
235
|
ctx.sim.runStages("beforeMovement", stepDt);
|
|
94
236
|
for (const userId of members.keys()) {
|
|
95
237
|
const frame = inputs.get(userId)?.frameAt(tick) ?? null;
|
|
96
|
-
if (frame !== null)
|
|
97
|
-
serverStep(ctx, userId, frame,
|
|
238
|
+
if (frame !== null && gameDt > 0)
|
|
239
|
+
serverStep(ctx, userId, frame, gameDt, movementTuning);
|
|
98
240
|
}
|
|
99
241
|
ctx.sim.runStages("afterMovement", stepDt);
|
|
100
|
-
loop.onTick?.(ctx,
|
|
101
|
-
advanceBehaviors(ctx,
|
|
242
|
+
loop.onTick?.(ctx, gameDt);
|
|
243
|
+
advanceBehaviors(ctx, gameDt);
|
|
102
244
|
ctx.sim.runStages("afterTick", stepDt);
|
|
245
|
+
for (const recorder of inputs.values())
|
|
246
|
+
compactRecorder(recorder, tick);
|
|
103
247
|
});
|
|
104
248
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
105
249
|
return replicator.commit();
|
|
@@ -107,6 +251,8 @@ export function createHostedGameRunner(options) {
|
|
|
107
251
|
diff: (sinceRevision) => replicator.diff(sinceRevision),
|
|
108
252
|
revision: () => replicator.revision(),
|
|
109
253
|
snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }),
|
|
254
|
+
state: ctx.state,
|
|
255
|
+
commit: () => replicator.commit(),
|
|
110
256
|
projectsViewers: () => ctx.replicatesPerViewer(),
|
|
111
257
|
members: () => Array.from(members.keys()),
|
|
112
258
|
context: () => ctx,
|
|
@@ -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,9 +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 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. */
|
|
4
10
|
export interface InputFrame {
|
|
5
11
|
held: readonly string[];
|
|
6
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[];
|
|
7
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. */
|
|
8
16
|
analog?: Readonly<Record<string, number>> | null;
|
|
9
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. */
|
|
@@ -16,8 +24,18 @@ export interface InputSnapshot {
|
|
|
16
24
|
weak: number;
|
|
17
25
|
ms: number;
|
|
18
26
|
}): Promise<boolean>;
|
|
19
|
-
/**
|
|
20
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Continuous rumble channels for a local user (`engine`, `road`, `impact`, …): set levels each tick
|
|
29
|
+
* and the shell mixes them by priority onto that user's pad. Local only — never in a snapshot or on the wire.
|
|
30
|
+
*/
|
|
31
|
+
haptics(userId: string): HapticChannels;
|
|
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;
|
|
21
39
|
/** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
|
|
22
40
|
publishPointer(state: PointerAxisState | null): void;
|
|
23
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`. */
|
|
@@ -25,7 +43,7 @@ export interface InputSnapshot {
|
|
|
25
43
|
isDown(action: string): boolean;
|
|
26
44
|
/** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
|
|
27
45
|
held(): readonly string[];
|
|
28
|
-
/** True
|
|
46
|
+
/** True on the sampled simulation step containing a press; legacy publishers use the last two held sets. */
|
|
29
47
|
justPressed(action: string): boolean;
|
|
30
48
|
/** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
|
|
31
49
|
justReleased(action: string): boolean;
|