@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAuthoredSimulation } from "../world/authoredSimulation.js";
|
|
1
2
|
import { createCommandRegistry } from "../commands/commandRegistry.js";
|
|
2
3
|
import {} from "../game/cosmetics.js";
|
|
3
4
|
import { groundFieldFor } from "../world/terrain.js";
|
|
@@ -12,6 +13,7 @@ import { createItemUse } from "../item/use.js";
|
|
|
12
13
|
import { createWeaponStats } from "../item/weapon.js";
|
|
13
14
|
import {} from "../scene/entityStats.js";
|
|
14
15
|
import {} from "../scene/entityStore.js";
|
|
16
|
+
import { installPursuitPersistence } from "../scene/behaviorRuntime.js";
|
|
15
17
|
import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
|
|
16
18
|
import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
|
|
17
19
|
import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
|
|
@@ -22,7 +24,7 @@ import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
|
|
|
22
24
|
import { createSimClock } from "../time/simClock.js";
|
|
23
25
|
import { createSimContext } from "./simContext.js";
|
|
24
26
|
import { seededRng } from "../random/rng.js";
|
|
25
|
-
import { createCameraDirector } from "./cameraDirector.js";
|
|
27
|
+
import { createCameraDirector, installCameraPersistence } from "./cameraDirector.js";
|
|
26
28
|
import { createParticleDirector } from "../vfx/particleDirector.js";
|
|
27
29
|
import { createInputSnapshot } from "./inputSnapshot.js";
|
|
28
30
|
import { baselineDescriptors } from "./descriptors/baseline.js";
|
|
@@ -62,6 +64,7 @@ export function createGameContext(options) {
|
|
|
62
64
|
content,
|
|
63
65
|
signalNotify: signal.notify,
|
|
64
66
|
ground,
|
|
67
|
+
solids,
|
|
65
68
|
events,
|
|
66
69
|
time,
|
|
67
70
|
...(occluder !== undefined ? { occluder } : {}),
|
|
@@ -195,12 +198,26 @@ export function createGameContext(options) {
|
|
|
195
198
|
// --- Combat installer (effects, projectiles, death, combat FX) ---
|
|
196
199
|
// Command runner closes over `ctx` once assembled (same late-bind pattern as before).
|
|
197
200
|
let ctxRef = null;
|
|
201
|
+
const { sweep, acceleration, targets, ...projectileBudgets } = definition.projectileTravel ?? {};
|
|
202
|
+
function projectileContext() {
|
|
203
|
+
if (ctxRef === null)
|
|
204
|
+
throw new Error("Projectile providers require an assembled GameContext");
|
|
205
|
+
return ctxRef;
|
|
206
|
+
}
|
|
198
207
|
const combat = createCombatSubsystem({
|
|
199
208
|
content,
|
|
200
209
|
signalNotify: signal.notify,
|
|
201
210
|
now,
|
|
202
211
|
events,
|
|
203
212
|
time,
|
|
213
|
+
projectileTravel: {
|
|
214
|
+
...projectileBudgets,
|
|
215
|
+
...(sweep === undefined ? {} : { sweep: (from, to, step) => sweep(projectileContext(), from, to, step) }),
|
|
216
|
+
...(targets === undefined ? {} : { targets: () => targets(projectileContext()) }),
|
|
217
|
+
acceleration: acceleration === undefined
|
|
218
|
+
? (position, _velocity, at, input) => authoredEnvironment.accelerationAt(position, at, input.travel?.windResponse ?? 0, input.travel?.maxAcceleration ?? 0, input.travel?.forceMask ?? 0)
|
|
219
|
+
: (position, velocity, at, input) => acceleration(projectileContext(), position, velocity, at, input),
|
|
220
|
+
},
|
|
204
221
|
entities,
|
|
205
222
|
objects,
|
|
206
223
|
combatSpatial,
|
|
@@ -214,12 +231,16 @@ export function createGameContext(options) {
|
|
|
214
231
|
lootRegistry,
|
|
215
232
|
spawnWorldItem,
|
|
216
233
|
despawnEntity,
|
|
217
|
-
runCommand(name, args) {
|
|
234
|
+
runCommand(name, args, actorUserId) {
|
|
218
235
|
if (ctxRef === null)
|
|
219
236
|
return;
|
|
220
|
-
|
|
237
|
+
if (actorUserId === undefined)
|
|
238
|
+
ctxRef.game.commands.run(name, args);
|
|
239
|
+
else
|
|
240
|
+
ctxRef.game.commands.runAs(actorUserId, name, args);
|
|
221
241
|
},
|
|
222
|
-
|
|
242
|
+
userIdOf: (instanceId) => instanceId === player.userId || featureValue("players")?.has(instanceId)
|
|
243
|
+
? instanceId : possession.ownerOf(instanceId),
|
|
223
244
|
rng,
|
|
224
245
|
...(definition.physics !== undefined ? { physics: definition.physics } : {}),
|
|
225
246
|
});
|
|
@@ -227,8 +248,9 @@ export function createGameContext(options) {
|
|
|
227
248
|
const { projectiles, combatFx, vfxInstances, effects } = combat;
|
|
228
249
|
const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
|
|
229
250
|
const { pile, loop, raceState, cardPiles, turnLoops } = createContextRegistries(signal.notify);
|
|
230
|
-
const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
|
|
251
|
+
const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning", "setRig", "restore", "reset"], signal.notify);
|
|
231
252
|
const particles = createParticleDirector();
|
|
253
|
+
const authoredEnvironment = createAuthoredSimulation({ document: definition.authoredDocument, timeSeconds: time.now, particles });
|
|
232
254
|
const input = createInputSnapshot();
|
|
233
255
|
// --- Descriptor install (single feature enable path) ---
|
|
234
256
|
const featureDeps = {
|
|
@@ -246,6 +268,7 @@ export function createGameContext(options) {
|
|
|
246
268
|
walletOf,
|
|
247
269
|
setWallet: (userId, state) => wallets.set(userId, state),
|
|
248
270
|
layouts,
|
|
271
|
+
itemTraits: playerSys.itemTraits,
|
|
249
272
|
inventoryFor,
|
|
250
273
|
ensureInstanceStats,
|
|
251
274
|
seedUserPool,
|
|
@@ -342,6 +365,20 @@ export function createGameContext(options) {
|
|
|
342
365
|
});
|
|
343
366
|
saveModules.push(module);
|
|
344
367
|
}
|
|
368
|
+
function reviveRestoredEntities(snapshot) {
|
|
369
|
+
if (!Object.hasOwn(snapshot, "entities") || !Array.isArray(snapshot["entities"]))
|
|
370
|
+
return;
|
|
371
|
+
for (const entity of entities.list()) {
|
|
372
|
+
const receive = content.entityById?.(entity.name)?.receive;
|
|
373
|
+
const alive = Object.values(receive ?? {}).every((rule) => {
|
|
374
|
+
const terminal = rule.order[rule.order.length - 1];
|
|
375
|
+
const pool = terminal === undefined ? undefined : statsByInstance.get(entity.id)?.[terminal];
|
|
376
|
+
return pool === undefined || pool.current > pool.min;
|
|
377
|
+
});
|
|
378
|
+
if (alive)
|
|
379
|
+
combat.death.revive(entity.id);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
345
382
|
// --- Assemble public GameContext ---
|
|
346
383
|
const ctx = {
|
|
347
384
|
rng,
|
|
@@ -352,11 +389,13 @@ export function createGameContext(options) {
|
|
|
352
389
|
despawn: despawnEntity,
|
|
353
390
|
update: entities.update,
|
|
354
391
|
setPose: entities.setPose,
|
|
392
|
+
setVelocity: entities.setVelocity,
|
|
355
393
|
setPoseConstraint: entities.setPoseConstraint,
|
|
356
394
|
get: entities.get,
|
|
357
395
|
list: entities.list,
|
|
358
396
|
ids: entities.ids,
|
|
359
397
|
subscribeMembership: entities.subscribeMembership,
|
|
398
|
+
subscribeBehaviors: entities.subscribeBehaviors,
|
|
360
399
|
spawnPoseOf: entities.spawnPoseOf,
|
|
361
400
|
resetToSpawn: entities.resetToSpawn,
|
|
362
401
|
resetAllToSpawn,
|
|
@@ -376,6 +415,9 @@ export function createGameContext(options) {
|
|
|
376
415
|
willHitProjectile: projectiles.willHitProjectile,
|
|
377
416
|
fireProjectile: projectiles.fireProjectile,
|
|
378
417
|
settleProjectile: projectiles.settleProjectile,
|
|
418
|
+
activeProjectiles: projectiles.activeProjectiles,
|
|
419
|
+
projectileState: projectiles.snapshot,
|
|
420
|
+
restoreProjectiles: projectiles.restore,
|
|
379
421
|
distance: spatial.distance,
|
|
380
422
|
inRadius: spatial.inRadius,
|
|
381
423
|
hasLineOfSight: spatial.hasLineOfSight,
|
|
@@ -409,6 +451,7 @@ export function createGameContext(options) {
|
|
|
409
451
|
raycast: (input) => sceneRaycast.raycast(input),
|
|
410
452
|
raycastAll: (input) => sceneRaycast.raycastAll(input),
|
|
411
453
|
},
|
|
454
|
+
environment: authoredEnvironment,
|
|
412
455
|
world: {
|
|
413
456
|
ground,
|
|
414
457
|
groundHeightAt: ground.sampleHeight,
|
|
@@ -507,7 +550,7 @@ export function createGameContext(options) {
|
|
|
507
550
|
weapon,
|
|
508
551
|
},
|
|
509
552
|
time,
|
|
510
|
-
sim: createSimContext({ config: definition.simulation, entities }),
|
|
553
|
+
sim: createSimContext({ config: definition.simulation, entities, time }),
|
|
511
554
|
camera,
|
|
512
555
|
particles,
|
|
513
556
|
input,
|
|
@@ -517,24 +560,46 @@ export function createGameContext(options) {
|
|
|
517
560
|
snapshot: (viewer) => composeWorldSnapshot(replicationModules, viewer),
|
|
518
561
|
hydrate(snapshot) {
|
|
519
562
|
applyWorldSnapshot(replicationModules, snapshot);
|
|
563
|
+
reviveRestoredEntities(snapshot);
|
|
564
|
+
signal.notify();
|
|
565
|
+
},
|
|
566
|
+
state: () => structuredClone(composeWorldSnapshot(saveModules)),
|
|
567
|
+
restore(state) {
|
|
568
|
+
applyWorldSnapshot(saveModules, structuredClone(state));
|
|
569
|
+
reviveRestoredEntities(state);
|
|
520
570
|
signal.notify();
|
|
521
571
|
},
|
|
522
572
|
replicationVersion,
|
|
523
573
|
replicatesPerViewer: () => projectsViewers,
|
|
524
574
|
};
|
|
525
575
|
ctxRef = ctx;
|
|
576
|
+
ctx.sim.addStage({ id: "authored-environment", phase: "afterTick", run: (_dt, _tick, gameDt) => authoredEnvironment.step(gameDt) });
|
|
577
|
+
ctx.sim.addStage({ id: "projectile-targets", phase: "beforeMovement", run: (_dt, _tick, gameDt) => combat.captureProjectileTargets(gameDt) });
|
|
578
|
+
ctx.sim.addStage({ id: "projectile-travel", phase: "afterTick", run: (_dt, _tick, gameDt) => projectiles.advanceProjectiles(gameDt, time.now()) });
|
|
579
|
+
registerSave({ key: "environment", snapshot: authoredEnvironment.snapshot, hydrate: (state) => authoredEnvironment.restore(state) });
|
|
580
|
+
registerSave({ key: "projectiles", snapshot: projectiles.snapshot, hydrate: (state) => projectiles.restore(state) });
|
|
581
|
+
registerSave({
|
|
582
|
+
key: "sim",
|
|
583
|
+
snapshot: () => ctx.sim.snapshot(),
|
|
584
|
+
hydrate: (state) => ctx.sim.restore(state),
|
|
585
|
+
decode: (raw) => {
|
|
586
|
+
if (raw === null || typeof raw !== "object" || !("loop" in raw) || !("poses" in raw))
|
|
587
|
+
return null;
|
|
588
|
+
return raw;
|
|
589
|
+
},
|
|
590
|
+
});
|
|
526
591
|
const physicsBackend = definition.physics?.backend;
|
|
527
592
|
if (physicsBackend !== undefined) {
|
|
528
|
-
ctx.sim.addStage({ id: "physics", phase: "afterMovement", run: (
|
|
593
|
+
ctx.sim.addStage({ id: "physics", phase: "afterMovement", run: (_dt, _tick, gameDt) => { if (gameDt > 0)
|
|
594
|
+
physicsBackend.step(gameDt); } });
|
|
529
595
|
}
|
|
596
|
+
installPursuitPersistence(ctx, aoiRadius, definition.pursuit?.eligible);
|
|
597
|
+
installCameraPersistence(ctx);
|
|
530
598
|
const saveOptions = resolveSaveOptions(definition, options);
|
|
531
599
|
if (saveOptions !== undefined) {
|
|
532
600
|
const saveTarget = {
|
|
533
|
-
snapshot:
|
|
534
|
-
hydrate:
|
|
535
|
-
applyWorldSnapshot(saveModules, snapshot);
|
|
536
|
-
signal.notify();
|
|
537
|
-
},
|
|
601
|
+
snapshot: ctx.state,
|
|
602
|
+
hydrate: ctx.restore,
|
|
538
603
|
subscribe: signal.subscribe,
|
|
539
604
|
};
|
|
540
605
|
ctx.game.save = createRuntimeSave({ target: saveTarget, ...saveOptions });
|
|
@@ -561,10 +626,11 @@ function resolveSaveOptions(definition, options) {
|
|
|
561
626
|
return undefined;
|
|
562
627
|
const config = persist === undefined || persist === true ? {} : persist;
|
|
563
628
|
return {
|
|
564
|
-
backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
|
|
629
|
+
backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(undefined, { errorMode: "throw" }),
|
|
565
630
|
mode: config.mode,
|
|
566
631
|
key: saveKey(definition.name),
|
|
567
632
|
version: config.version,
|
|
633
|
+
migrate: config.migrate,
|
|
568
634
|
autosaveMs: config.autosaveMs,
|
|
569
635
|
now: options.now,
|
|
570
636
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthoredSimulationRuntime } from "../world/authoredSimulation.js";
|
|
1
2
|
import type { Territory } from "../world/territory.js";
|
|
2
3
|
import type { CurrencyDefinition } from "../economy/currency.js";
|
|
3
4
|
import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
|
|
@@ -257,11 +258,14 @@ export interface SceneEntityContext {
|
|
|
257
258
|
despawn(instanceId: string): boolean;
|
|
258
259
|
update: EntityStore["update"];
|
|
259
260
|
setPose(instanceId: string, pose: EntityPose): boolean;
|
|
261
|
+
/** Set finite world velocity without changing pose; invalid tuples and missing entities return false unchanged. */
|
|
262
|
+
setVelocity: EntityStore["setVelocity"];
|
|
260
263
|
setPoseConstraint: EntityStore["setPoseConstraint"];
|
|
261
264
|
get(instanceId: string): SceneEntity | null;
|
|
262
265
|
list(): readonly SceneEntity[];
|
|
263
266
|
ids: EntityStore["ids"];
|
|
264
267
|
subscribeMembership: EntityStore["subscribeMembership"];
|
|
268
|
+
subscribeBehaviors: EntityStore["subscribeBehaviors"];
|
|
265
269
|
spawnPoseOf(instanceId: string): SpawnPose | null;
|
|
266
270
|
resetToSpawn(instanceId: string): boolean;
|
|
267
271
|
resetAllToSpawn(filter?: (entity: SceneEntity) => boolean): number;
|
|
@@ -287,6 +291,10 @@ export interface SceneEntityContext {
|
|
|
287
291
|
willHitProjectile: ProjectileSystem["willHitProjectile"];
|
|
288
292
|
fireProjectile: ProjectileSystem["fireProjectile"];
|
|
289
293
|
settleProjectile: ProjectileSystem["settleProjectile"];
|
|
294
|
+
/** Live travel poses for game-owned arrow/projectile appearance. */
|
|
295
|
+
activeProjectiles: ProjectileSystem["activeProjectiles"];
|
|
296
|
+
projectileState: ProjectileSystem["snapshot"];
|
|
297
|
+
restoreProjectiles: ProjectileSystem["restore"];
|
|
290
298
|
distance: SpatialApi["distance"];
|
|
291
299
|
inRadius: SpatialApi["inRadius"];
|
|
292
300
|
hasLineOfSight: SpatialApi["hasLineOfSight"];
|
|
@@ -458,6 +466,8 @@ export interface GameContext {
|
|
|
458
466
|
raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
|
|
459
467
|
};
|
|
460
468
|
world: GameContextWorld;
|
|
469
|
+
/** Authored weather, shelter, accumulation and fire sampled from ctx.time. */
|
|
470
|
+
environment: AuthoredSimulationRuntime;
|
|
461
471
|
/**
|
|
462
472
|
* Deterministic `[0,1)` stream for this world — loot, AI, combat rolls, and any sim path that
|
|
463
473
|
* needs randomness. Same seed → same sequence. Never fall back to `Math.random` in systems;
|
|
@@ -571,6 +581,10 @@ export interface GameContext {
|
|
|
571
581
|
snapshot(viewer?: SnapshotViewer): WorldSnapshot;
|
|
572
582
|
/** Apply a {@link WorldSnapshot} from an authoritative host, hydrating each subsystem key present in it. */
|
|
573
583
|
hydrate(snapshot: WorldSnapshot): void;
|
|
584
|
+
/** Detached authoritative state, including save-only modules. Use for persistence, never client replication; available without `persist` or a save backend. */
|
|
585
|
+
state(): WorldSnapshot;
|
|
586
|
+
/** Restore authoritative state after initialization. Missing keys retain their initialized values, including in legacy replication-only saves. */
|
|
587
|
+
restore(state: WorldSnapshot): void;
|
|
574
588
|
/** Aggregate world-dirty version summed across replicated modules; the host replicator skips an unchanged commit. @internal */
|
|
575
589
|
replicationVersion(): number;
|
|
576
590
|
/** True when a {@link GameContextOptions.replication} policy makes {@link snapshot} viewer-dependent (private/AOI projection is active). @internal */
|
|
@@ -2,10 +2,12 @@ import type { GameDefinition, GameLoop } from "../game/defineGame.js";
|
|
|
2
2
|
import type { PointerAxisState } from "../input/pointerAxis.js";
|
|
3
3
|
import type { ModelAssetRef } from "../scene/assetCatalog.js";
|
|
4
4
|
import { type GameContext, type GameContextContent, type GameContextModels } from "./gameContext.js";
|
|
5
|
-
import type { InputSnapshot } from "./inputSnapshot.js";
|
|
5
|
+
import type { InputFrame, InputSnapshot } from "./inputSnapshot.js";
|
|
6
6
|
import { type SimSnapshot } from "./simSnapshot.js";
|
|
7
7
|
/** One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame. */
|
|
8
8
|
export interface HeadlessInput {
|
|
9
|
+
/** Discrete presses on this recorded step, including taps released before its held-state sample. */
|
|
10
|
+
presses?: InputFrame["presses"];
|
|
9
11
|
/** Replaces the held-action set for this step; edge detection (`justPressed`/`justReleased`) rolls the previous set forward. Omit to keep the last published set. */
|
|
10
12
|
held?: readonly string[];
|
|
11
13
|
/** Replaces the normalized pointer state; omit to leave the last pointer unchanged (pass `null` to clear it). */
|
|
@@ -18,15 +18,14 @@ export function createHeadlessRunner(options) {
|
|
|
18
18
|
loop?.onNewPlayer?.(ctx);
|
|
19
19
|
const simSnapshot = createContextSimSnapshot(ctx);
|
|
20
20
|
const tuning = options.playerMovement === true
|
|
21
|
-
? resolvePlayerMovementTuning({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
? { ...resolvePlayerMovementTuning({
|
|
22
|
+
physics: definition.physics,
|
|
23
|
+
world: definition.world,
|
|
24
|
+
movement: options.movement,
|
|
25
|
+
}), authoritativeStep: true }
|
|
26
26
|
: null;
|
|
27
27
|
function publishInput(input) {
|
|
28
|
-
|
|
29
|
-
ctx.input.publish(input.held);
|
|
28
|
+
ctx.input.publish(input.held ?? ctx.input.held(), { pressed: input.presses?.map(press => press.action) ?? [] });
|
|
30
29
|
if (input.pointer !== undefined)
|
|
31
30
|
ctx.input.publishPointer(input.pointer);
|
|
32
31
|
}
|
|
@@ -54,12 +53,12 @@ export function createHeadlessRunner(options) {
|
|
|
54
53
|
publishInput(input);
|
|
55
54
|
const dt = Math.min(dtSeconds, maxStep);
|
|
56
55
|
let total = 0;
|
|
57
|
-
ctx.sim.advance(dt, (stepDt) => {
|
|
58
|
-
|
|
56
|
+
ctx.sim.advance(dt, (stepDt, _tick, gameDt) => {
|
|
57
|
+
ctx.input.beginStep();
|
|
59
58
|
total += gameDt;
|
|
60
59
|
ctx.sim.runStages("beforeMovement", stepDt);
|
|
61
|
-
if (tuning !== null) {
|
|
62
|
-
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() },
|
|
60
|
+
if (tuning !== null && gameDt > 0) {
|
|
61
|
+
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, options.heading, options.pitch);
|
|
63
62
|
}
|
|
64
63
|
ctx.sim.runStages("afterMovement", stepDt);
|
|
65
64
|
loop?.onTick?.(ctx, gameDt);
|
|
@@ -71,6 +70,7 @@ export function createHeadlessRunner(options) {
|
|
|
71
70
|
snapshot: () => simSnapshot.capture(),
|
|
72
71
|
restore(snapshot) {
|
|
73
72
|
simSnapshot.restore(snapshot);
|
|
73
|
+
ctx.input.publish([], { reset: true });
|
|
74
74
|
},
|
|
75
75
|
tick: () => ctx.sim.tick(),
|
|
76
76
|
};
|
|
@@ -21,8 +21,17 @@ export interface HostedGameRunnerOptions<TAssetRef extends ModelAssetRef, TMulti
|
|
|
21
21
|
* snapshot overlays the world state it seeded. Omit for a long-lived stateful host (ws) that keeps one runner.
|
|
22
22
|
*/
|
|
23
23
|
restore?: WorldSnapshot;
|
|
24
|
+
/** Persisted replication cursor; restored hosts continue above it. */
|
|
25
|
+
revision?: number;
|
|
24
26
|
/** Render-model lookup for collider auto-fit — pass the same lookup the shell derives from `entityModels`/`objectModels` so host and clients resolve identical hitboxes. */
|
|
25
27
|
models?: GameContextModels;
|
|
28
|
+
/** Process-local reconnect protection. Departed admission marks expire after 5 minutes or the oldest is evicted above 256 users; zero disables retention. Live members and staged replay ticks are never evicted. */
|
|
29
|
+
inputRetention?: {
|
|
30
|
+
maxDepartedUsers?: number;
|
|
31
|
+
departedTtlMs?: number;
|
|
32
|
+
/** Wall-clock milliseconds; injectable for deterministic hosts and tests. */
|
|
33
|
+
nowMs?: () => number;
|
|
34
|
+
};
|
|
26
35
|
}
|
|
27
36
|
/**
|
|
28
37
|
* The GameContext-loop equivalent of the pure-reducer `createGameHost`: one authoritative `createGameContext`
|
|
@@ -36,8 +45,18 @@ export interface HostedGameRunner {
|
|
|
36
45
|
/** Re-attach an already-joined member on a reconstructed runner — restores membership and `ctx.game.players` without re-firing `onNewPlayer`. Stateless hosts call it once per persisted member after `restore`. */
|
|
37
46
|
resume(userId: string): void;
|
|
38
47
|
leave(userId: string): void;
|
|
39
|
-
/** Record a client's latest input frame
|
|
48
|
+
/** Record a joined/resumed client's latest input frame; nonmember input is ignored. Stashed for {@link heldInput} and sampled onto `ctx.game.players` in `onTick`. */
|
|
40
49
|
input(userId: string, frame: InputFrame): void;
|
|
50
|
+
/** Counts actual retained input storage for host health checks; elapsed recorder history is excluded and staged future replay ticks remain. */
|
|
51
|
+
inputStats(): {
|
|
52
|
+
members: number;
|
|
53
|
+
recorders: number;
|
|
54
|
+
recordedFrames: number;
|
|
55
|
+
latestInputs: number;
|
|
56
|
+
wireSequences: number;
|
|
57
|
+
pressSequences: number;
|
|
58
|
+
departedUsers: number;
|
|
59
|
+
};
|
|
41
60
|
heldInput(userId: string): InputFrame | null;
|
|
42
61
|
command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
|
|
43
62
|
tick(dt: number): number;
|
|
@@ -45,6 +64,10 @@ export interface HostedGameRunner {
|
|
|
45
64
|
revision(): number;
|
|
46
65
|
/** The full world baseline — projected to only what `viewer` may see when the context carries a replication policy, else the whole world. */
|
|
47
66
|
snapshot(viewer?: SnapshotViewer): WorldSnapshot;
|
|
67
|
+
/** Complete detached authoritative state for persistence, including save-only modules. */
|
|
68
|
+
state(): WorldSnapshot;
|
|
69
|
+
/** Commit replication without advancing simulation or running game ticks. */
|
|
70
|
+
commit(): number;
|
|
48
71
|
/** True when {@link snapshot} is viewer-dependent (a replication policy projects private/AOI state); a host must then serve each viewer its own frame. */
|
|
49
72
|
projectsViewers(): boolean;
|
|
50
73
|
members(): readonly string[];
|
|
@@ -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,
|