@jgengine/core 0.19.0 → 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 +346 -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 +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 +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 +333 -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/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +1 -1
- 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/jgengine/SKILL.md +22 -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 +200 -57
- 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 +152 -5
- package/skills/jgengine-world/visibility.md +28 -1
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { DEFAULT_STREAMING_SETTINGS, mergeStreamingSettings } from "./settings.js";
|
|
2
2
|
import { nowMs } from "./diagnostics.js";
|
|
3
|
+
/** Stream shared resources with bounded load concurrency, per-tick resident-byte eviction, and explicit inactive-history cleanup. */
|
|
3
4
|
export function createAssetStreamingSystem(options) {
|
|
4
|
-
|
|
5
|
+
function mergeSettings(base, patch) {
|
|
6
|
+
const next = mergeStreamingSettings(base, patch);
|
|
7
|
+
if (!Number.isSafeInteger(next.maxConcurrentLoads) || next.maxConcurrentLoads < 0) {
|
|
8
|
+
throw new RangeError("AssetStreamingSystem: maxConcurrentLoads must be a nonnegative safe integer");
|
|
9
|
+
}
|
|
10
|
+
if (next.maxResidentBytes !== Infinity && (!Number.isFinite(next.maxResidentBytes) || next.maxResidentBytes < 0)) {
|
|
11
|
+
throw new RangeError("AssetStreamingSystem: maxResidentBytes must be nonnegative and finite, or Infinity");
|
|
12
|
+
}
|
|
13
|
+
if (next.residentEvictionOrder !== "oldest" && next.residentEvictionOrder !== "largest") {
|
|
14
|
+
throw new RangeError("AssetStreamingSystem: residentEvictionOrder must be oldest or largest");
|
|
15
|
+
}
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
let settings = mergeSettings(DEFAULT_STREAMING_SETTINGS, options.settings ?? {});
|
|
5
19
|
const clock = options.now ?? nowMs;
|
|
6
20
|
const records = new Map();
|
|
7
21
|
const queued = new Set();
|
|
22
|
+
const residents = new Set();
|
|
23
|
+
const unknownSizes = new Set();
|
|
8
24
|
const signals = new Map();
|
|
9
25
|
const inFlight = new Set();
|
|
10
26
|
let unloadedTotal = 0;
|
|
11
27
|
let cancelledTotal = 0;
|
|
28
|
+
let budgetEvictedTotal = 0;
|
|
12
29
|
function ensure(id) {
|
|
13
30
|
let record = records.get(id);
|
|
14
31
|
if (record === undefined) {
|
|
@@ -31,29 +48,56 @@ export function createAssetStreamingSystem(options) {
|
|
|
31
48
|
record.state = "loading";
|
|
32
49
|
const signal = { cancelled: false };
|
|
33
50
|
signals.set(record.id, signal);
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
let loading;
|
|
52
|
+
try {
|
|
53
|
+
loading = options.load(record.id, signal);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
loading = Promise.reject(error);
|
|
57
|
+
}
|
|
58
|
+
const promise = loading
|
|
36
59
|
.then((result) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
record.state = "unloaded";
|
|
60
|
+
if (signal.cancelled || records.get(record.id) !== record) {
|
|
61
|
+
options.unload?.(record.id);
|
|
40
62
|
return;
|
|
41
63
|
}
|
|
42
64
|
record.state = "loaded";
|
|
43
|
-
|
|
65
|
+
const knownSize = typeof result.bytes === "number" && Number.isFinite(result.bytes) && result.bytes >= 0;
|
|
66
|
+
record.bytes = knownSize ? result.bytes : 0;
|
|
67
|
+
if (!knownSize)
|
|
68
|
+
unknownSizes.add(record);
|
|
44
69
|
record.value = result.value;
|
|
45
70
|
record.lastActiveMs = clock();
|
|
71
|
+
residents.add(record);
|
|
46
72
|
})
|
|
47
73
|
.catch(() => {
|
|
48
|
-
|
|
49
|
-
if (!signal.cancelled)
|
|
74
|
+
if (!signal.cancelled && records.get(record.id) === record)
|
|
50
75
|
record.state = "error";
|
|
51
76
|
})
|
|
52
77
|
.finally(() => {
|
|
78
|
+
if (signals.get(record.id) === signal)
|
|
79
|
+
signals.delete(record.id);
|
|
53
80
|
inFlight.delete(promise);
|
|
54
81
|
});
|
|
55
82
|
inFlight.add(promise);
|
|
56
83
|
}
|
|
84
|
+
function unprotected(record) {
|
|
85
|
+
return record.state === "loaded" && !record.pinned && record.refCount === 0;
|
|
86
|
+
}
|
|
87
|
+
function compareEviction(a, b) {
|
|
88
|
+
if (settings.residentEvictionOrder === "largest" && a.bytes !== b.bytes)
|
|
89
|
+
return b.bytes - a.bytes;
|
|
90
|
+
return a.lastActiveMs - b.lastActiveMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
91
|
+
}
|
|
92
|
+
function unload(record) {
|
|
93
|
+
options.unload?.(record.id);
|
|
94
|
+
residents.delete(record);
|
|
95
|
+
unknownSizes.delete(record);
|
|
96
|
+
record.state = "unloaded";
|
|
97
|
+
record.bytes = 0;
|
|
98
|
+
record.value = undefined;
|
|
99
|
+
unloadedTotal += 1;
|
|
100
|
+
}
|
|
57
101
|
function evictable(record, now) {
|
|
58
102
|
if (record.state !== "loaded")
|
|
59
103
|
return false;
|
|
@@ -92,29 +136,45 @@ export function createAssetStreamingSystem(options) {
|
|
|
92
136
|
},
|
|
93
137
|
tick() {
|
|
94
138
|
const now = clock();
|
|
95
|
-
if (queued.size > 0) {
|
|
139
|
+
if (queued.size > 0 && signals.size < settings.maxConcurrentLoads) {
|
|
96
140
|
const pending = [];
|
|
97
141
|
for (const id of queued) {
|
|
98
142
|
const record = records.get(id);
|
|
99
|
-
if (record !== undefined)
|
|
143
|
+
if (record !== undefined && !signals.has(id))
|
|
100
144
|
pending.push(record);
|
|
101
145
|
}
|
|
102
146
|
pending.sort((a, b) => b.priority - a.priority);
|
|
103
|
-
const budget = Math.min(settings.maxLoadsPerFrame, pending.length);
|
|
104
|
-
for (let i = 0; i < budget; i += 1)
|
|
105
|
-
|
|
147
|
+
const budget = Math.min(settings.maxLoadsPerFrame, settings.maxConcurrentLoads - signals.size, pending.length);
|
|
148
|
+
for (let i = 0; i < budget && signals.size < settings.maxConcurrentLoads; i += 1) {
|
|
149
|
+
const record = pending[i];
|
|
150
|
+
if (queued.has(record.id) && records.get(record.id) === record && !signals.has(record.id))
|
|
151
|
+
startLoad(record);
|
|
152
|
+
}
|
|
106
153
|
}
|
|
154
|
+
let bytes = 0;
|
|
155
|
+
for (const record of residents)
|
|
156
|
+
bytes += record.bytes;
|
|
107
157
|
let unloads = 0;
|
|
108
|
-
|
|
158
|
+
while (bytes > settings.maxResidentBytes && unloads < settings.maxUnloadsPerFrame) {
|
|
159
|
+
let candidate;
|
|
160
|
+
for (const record of residents) {
|
|
161
|
+
if (record.bytes > 0 && unprotected(record) && (candidate === undefined || compareEviction(record, candidate) < 0))
|
|
162
|
+
candidate = record;
|
|
163
|
+
}
|
|
164
|
+
if (candidate === undefined)
|
|
165
|
+
break;
|
|
166
|
+
const releasedBytes = candidate.bytes;
|
|
167
|
+
unload(candidate);
|
|
168
|
+
bytes -= releasedBytes;
|
|
169
|
+
budgetEvictedTotal += 1;
|
|
170
|
+
unloads += 1;
|
|
171
|
+
}
|
|
172
|
+
for (const record of residents) {
|
|
109
173
|
if (unloads >= settings.maxUnloadsPerFrame)
|
|
110
174
|
break;
|
|
111
175
|
if (!evictable(record, now))
|
|
112
176
|
continue;
|
|
113
|
-
|
|
114
|
-
record.state = "unloaded";
|
|
115
|
-
record.bytes = 0;
|
|
116
|
-
record.value = undefined;
|
|
117
|
-
unloadedTotal += 1;
|
|
177
|
+
unload(record);
|
|
118
178
|
unloads += 1;
|
|
119
179
|
}
|
|
120
180
|
},
|
|
@@ -127,7 +187,7 @@ export function createAssetStreamingSystem(options) {
|
|
|
127
187
|
return;
|
|
128
188
|
}
|
|
129
189
|
const signal = signals.get(assetId);
|
|
130
|
-
if (signal !== undefined) {
|
|
190
|
+
if (signal !== undefined && !signal.cancelled) {
|
|
131
191
|
signal.cancelled = true;
|
|
132
192
|
const record = records.get(assetId);
|
|
133
193
|
if (record !== undefined)
|
|
@@ -135,6 +195,9 @@ export function createAssetStreamingSystem(options) {
|
|
|
135
195
|
cancelledTotal += 1;
|
|
136
196
|
}
|
|
137
197
|
},
|
|
198
|
+
retune(patch) {
|
|
199
|
+
settings = mergeSettings(settings, patch);
|
|
200
|
+
},
|
|
138
201
|
stateOf(assetId) {
|
|
139
202
|
return records.get(assetId)?.state;
|
|
140
203
|
},
|
|
@@ -144,8 +207,16 @@ export function createAssetStreamingSystem(options) {
|
|
|
144
207
|
record(assetId) {
|
|
145
208
|
return records.get(assetId);
|
|
146
209
|
},
|
|
210
|
+
forget(assetId) {
|
|
211
|
+
const record = records.get(assetId);
|
|
212
|
+
if (record === undefined || record.pinned || record.refCount > 0 || signals.has(assetId) || queued.has(assetId))
|
|
213
|
+
return false;
|
|
214
|
+
if (record.state !== "unloaded" && record.state !== "error")
|
|
215
|
+
return false;
|
|
216
|
+
return records.delete(assetId);
|
|
217
|
+
},
|
|
147
218
|
stats() {
|
|
148
|
-
let queuedCount = 0, loading = 0, loaded = 0, errored = 0, bytes = 0;
|
|
219
|
+
let queuedCount = 0, loading = 0, loaded = 0, errored = 0, bytes = 0, protectedBytes = 0, unknownSizeLoaded = 0;
|
|
149
220
|
for (const record of records.values()) {
|
|
150
221
|
switch (record.state) {
|
|
151
222
|
case "queued":
|
|
@@ -157,6 +228,10 @@ export function createAssetStreamingSystem(options) {
|
|
|
157
228
|
case "loaded":
|
|
158
229
|
loaded += 1;
|
|
159
230
|
bytes += record.bytes;
|
|
231
|
+
if (record.pinned || record.refCount > 0)
|
|
232
|
+
protectedBytes += record.bytes;
|
|
233
|
+
if (unknownSizes.has(record))
|
|
234
|
+
unknownSizeLoaded += 1;
|
|
160
235
|
break;
|
|
161
236
|
case "error":
|
|
162
237
|
errored += 1;
|
|
@@ -172,6 +247,11 @@ export function createAssetStreamingSystem(options) {
|
|
|
172
247
|
inFlight: inFlight.size,
|
|
173
248
|
cancelled: cancelledTotal,
|
|
174
249
|
bytes,
|
|
250
|
+
records: records.size,
|
|
251
|
+
protectedBytes,
|
|
252
|
+
unknownSizeLoaded,
|
|
253
|
+
overBudgetBytes: Math.max(0, bytes - settings.maxResidentBytes),
|
|
254
|
+
budgetEvicted: budgetEvictedTotal,
|
|
175
255
|
};
|
|
176
256
|
},
|
|
177
257
|
applyTo(stats) {
|
|
@@ -190,12 +270,16 @@ export function createAssetStreamingSystem(options) {
|
|
|
190
270
|
clear() {
|
|
191
271
|
for (const signal of signals.values())
|
|
192
272
|
signal.cancelled = true;
|
|
273
|
+
const loaded = [...records.values()].filter((record) => record.state === "loaded").map((record) => record.id);
|
|
193
274
|
records.clear();
|
|
194
275
|
queued.clear();
|
|
195
|
-
|
|
196
|
-
|
|
276
|
+
residents.clear();
|
|
277
|
+
unknownSizes.clear();
|
|
278
|
+
budgetEvictedTotal = 0;
|
|
197
279
|
unloadedTotal = 0;
|
|
198
280
|
cancelledTotal = 0;
|
|
281
|
+
for (const id of loaded)
|
|
282
|
+
options.unload?.(id);
|
|
199
283
|
},
|
|
200
284
|
};
|
|
201
285
|
}
|
|
@@ -23,11 +23,17 @@ export interface StreamingSettings {
|
|
|
23
23
|
readonly enabled: boolean;
|
|
24
24
|
/** Extra world units around the camera whose assets are preloaded ahead of visibility. */
|
|
25
25
|
readonly preloadMargin: number;
|
|
26
|
-
/**
|
|
26
|
+
/** Idle unload delay; resident-byte pressure can evict unprotected assets sooner. */
|
|
27
27
|
readonly unloadGraceSeconds: number;
|
|
28
28
|
readonly maxLoadsPerFrame: number;
|
|
29
|
+
/** Maximum unresolved loads, including cancelled work until its loader settles. Zero pauses new loads. */
|
|
30
|
+
readonly maxConcurrentLoads: number;
|
|
29
31
|
readonly maxUnloadsPerFrame: number;
|
|
30
|
-
/**
|
|
32
|
+
/** Resident-byte target, enforced on ticks within maxUnloadsPerFrame; protected assets may exceed it. */
|
|
33
|
+
readonly maxResidentBytes: number;
|
|
34
|
+
/** Budget eviction order. Ties use oldest activity then asset id for deterministic selection. */
|
|
35
|
+
readonly residentEvictionOrder: "oldest" | "largest";
|
|
36
|
+
/** Small shared assets survive idle unloading, but remain eligible under resident-byte pressure. */
|
|
31
37
|
readonly keepResidentBytes: number;
|
|
32
38
|
}
|
|
33
39
|
export declare const DEFAULT_STREAMING_SETTINGS: StreamingSettings;
|
|
@@ -13,8 +13,11 @@ export const DEFAULT_STREAMING_SETTINGS = {
|
|
|
13
13
|
preloadMargin: 32,
|
|
14
14
|
unloadGraceSeconds: 10,
|
|
15
15
|
maxLoadsPerFrame: 4,
|
|
16
|
+
maxConcurrentLoads: 4,
|
|
16
17
|
maxUnloadsPerFrame: 2,
|
|
17
18
|
keepResidentBytes: 64 * 1024,
|
|
19
|
+
maxResidentBytes: Infinity,
|
|
20
|
+
residentEvictionOrder: "oldest",
|
|
18
21
|
};
|
|
19
22
|
const EMPTY = {};
|
|
20
23
|
/** Merge override layers by precedence: earlier arguments win. Pass object, then layer, then scene, then global. */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { LootPipeline, LootResolution } from "../game/lootPipeline.js";
|
|
2
|
+
import type { Drop } from "../game/lootTable.js";
|
|
3
|
+
import { type RandomSeed } from "../random/rng.js";
|
|
4
|
+
/** Timestamped serializable event. `at` is epoch milliseconds, compatible with `appendFeed`. */
|
|
5
|
+
export interface ExpeditionEvent {
|
|
6
|
+
at: number;
|
|
7
|
+
/** Lifecycle kinds are loot/hazard/supply/death/recall/return; encounter notices may use caller kinds. */
|
|
8
|
+
kind: string;
|
|
9
|
+
text: string;
|
|
10
|
+
/** Accepted drops; `loot.drops` records the full roll before the item-unit capacity bound. */
|
|
11
|
+
drops?: readonly Drop[];
|
|
12
|
+
loot?: LootResolution;
|
|
13
|
+
hpDelta?: number;
|
|
14
|
+
exposureDelta?: number;
|
|
15
|
+
supply?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Plain saveable expedition data. Persist this whole value, including the RNG cursor and tick frontier. */
|
|
18
|
+
export interface ExpeditionState {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly status: "exploring" | "returning" | "home" | "dead";
|
|
21
|
+
readonly startedAtMs: number;
|
|
22
|
+
/** Last completed event tick (arrival time after return), never an unprocessed catch-up target. */
|
|
23
|
+
readonly lastSettledMs: number;
|
|
24
|
+
readonly elapsedMs: number;
|
|
25
|
+
readonly returnUntilMs: number | null;
|
|
26
|
+
readonly hp: number;
|
|
27
|
+
/** Caller-interpreted hazard meter; its meaning and effective max-HP policy are injected. */
|
|
28
|
+
readonly exposure: number;
|
|
29
|
+
readonly supplies: Readonly<Record<string, number>>;
|
|
30
|
+
/** Counts are merged by item/currency id; currency does not consume item capacity. */
|
|
31
|
+
readonly carried: readonly Drop[];
|
|
32
|
+
readonly log: readonly ExpeditionEvent[];
|
|
33
|
+
readonly seed: string;
|
|
34
|
+
readonly cursor: RandomSeed;
|
|
35
|
+
}
|
|
36
|
+
/** Dispatch data, detached from the caller's supply record. All timestamps are epoch milliseconds. */
|
|
37
|
+
export interface ExpeditionDispatch {
|
|
38
|
+
id: string;
|
|
39
|
+
startAtMs: number;
|
|
40
|
+
hp: number;
|
|
41
|
+
exposure?: number;
|
|
42
|
+
supplies?: Readonly<Record<string, number>>;
|
|
43
|
+
seed: string;
|
|
44
|
+
}
|
|
45
|
+
/** One encounter's mechanical effects and optional caller-authored notices. */
|
|
46
|
+
export interface ExpeditionHazard {
|
|
47
|
+
damage?: number;
|
|
48
|
+
exposure?: number;
|
|
49
|
+
notices?: readonly {
|
|
50
|
+
kind: string;
|
|
51
|
+
text: string;
|
|
52
|
+
}[];
|
|
53
|
+
}
|
|
54
|
+
/** Ordered auto-consumption rule: spends one finite supply only when its declared effect changes a vital. */
|
|
55
|
+
export interface ExpeditionSupplyRule {
|
|
56
|
+
key: string;
|
|
57
|
+
when: (state: ExpeditionState) => boolean;
|
|
58
|
+
restoreHp?: number;
|
|
59
|
+
clearExposure?: number;
|
|
60
|
+
}
|
|
61
|
+
/** Runtime policy, supplied on each transition and never saved. Callbacks must be pure. */
|
|
62
|
+
export interface ExpeditionConfig<TCtx> {
|
|
63
|
+
/** Positive whole milliseconds between events; cadence retuning anchors at the last completed tick. */
|
|
64
|
+
tickMs: number;
|
|
65
|
+
loot: LootPipeline<TCtx>;
|
|
66
|
+
lootCtx: (state: ExpeditionState) => TCtx;
|
|
67
|
+
maxHp: (state: ExpeditionState) => number;
|
|
68
|
+
hazard?: (state: ExpeditionState, rng: () => number) => ExpeditionHazard;
|
|
69
|
+
supplies?: readonly ExpeditionSupplyRule[];
|
|
70
|
+
/** Nonnegative fraction of exploring elapsed time spent returning. */
|
|
71
|
+
returnFraction: number;
|
|
72
|
+
/** Positive whole item units; currency is carried without consuming item slots. */
|
|
73
|
+
carryCap: number;
|
|
74
|
+
/** Bounded work per call, default 10,000; excess ticks remain unsettled for the next call. */
|
|
75
|
+
maxTicksPerSettle?: number;
|
|
76
|
+
/** Newest event count retained by appendFeed, default 200; rewards and RNG are retained separately. */
|
|
77
|
+
logLimit?: number;
|
|
78
|
+
/** Optional alternative persisted RNG recurrence; returns a [0,1) value and its next cursor. */
|
|
79
|
+
randomStep?: (cursor: RandomSeed) => readonly [number, RandomSeed];
|
|
80
|
+
/** All lifecycle prose is caller-authored; omit for headless logs with empty text. */
|
|
81
|
+
describe?: (event: Omit<ExpeditionEvent, "text">, state: ExpeditionState) => string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Dispatch plain expedition state with a persisted seeded cursor; no clock, scheduler or storage is owned.
|
|
85
|
+
*
|
|
86
|
+
* @capability expedition-dispatch dispatch a saveable timed away mission with finite supplies and a seeded RNG cursor
|
|
87
|
+
*/
|
|
88
|
+
export declare function dispatchExpedition(input: ExpeditionDispatch): ExpeditionState;
|
|
89
|
+
/**
|
|
90
|
+
* Replay due event ticks using the existing stepped catch-up primitive. Order is loot → hazard → ordered
|
|
91
|
+
* supplies → death → capacity recall. Unprocessed ticks retain their frontier; returning never rolls or
|
|
92
|
+
* takes damage. Auto-recall uses the triggering tick's time, including offline arrival in the same call.
|
|
93
|
+
*
|
|
94
|
+
* @capability expedition-settle settle bounded offline expedition ticks without losing supplies rewards or RNG continuity
|
|
95
|
+
*/
|
|
96
|
+
export declare function settleExpedition<TCtx>(config: ExpeditionConfig<TCtx>, state: ExpeditionState, nowMs: number): ExpeditionState;
|
|
97
|
+
/**
|
|
98
|
+
* Settle first, then recall safely. If the catch-up budget leaves due exploring ticks, return that state
|
|
99
|
+
* without recalling; repeat at the same target until the frontier catches up. Dead state never revives.
|
|
100
|
+
*
|
|
101
|
+
* @capability expedition-recall begin a safe fractional-time return after settling the pending expedition frontier
|
|
102
|
+
*/
|
|
103
|
+
export declare function recallExpedition<TCtx>(config: ExpeditionConfig<TCtx>, state: ExpeditionState, nowMs: number): ExpeditionState;
|
|
104
|
+
/**
|
|
105
|
+
* Settle first, then complete a live return instantly. Caller owns payment/authorization; pending ticks
|
|
106
|
+
* remain unsettled when the budget is exhausted, and dead/home state is never changed.
|
|
107
|
+
*
|
|
108
|
+
* @capability expedition-return complete an authorized instant return without skipping unsettled ticks or reviving death
|
|
109
|
+
*/
|
|
110
|
+
export declare function returnExpeditionNow<TCtx>(config: ExpeditionConfig<TCtx>, state: ExpeditionState, nowMs: number): ExpeditionState;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { appendFeed } from "../game/feed.js";
|
|
2
|
+
import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
|
|
3
|
+
import { accrueSince } from "../time/accrueSince.js";
|
|
4
|
+
import { steppedCatchUp } from "../time/idleProgress.js";
|
|
5
|
+
function nonnegative(value, name) {
|
|
6
|
+
if (!Number.isFinite(value) || value < 0)
|
|
7
|
+
throw new RangeError(`${name} must be finite and nonnegative`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function positiveInteger(value, name) {
|
|
11
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
12
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function validate(config, state, nowMs) {
|
|
16
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(state.startedAtMs) || !Number.isFinite(state.lastSettledMs))
|
|
17
|
+
throw new RangeError("Expedition timestamps must be finite");
|
|
18
|
+
if (state.lastSettledMs < state.startedAtMs || (state.returnUntilMs !== null && !Number.isFinite(state.returnUntilMs)))
|
|
19
|
+
throw new RangeError("Invalid expedition time frontier");
|
|
20
|
+
if (!Number.isSafeInteger(state.cursor))
|
|
21
|
+
throw new RangeError("Expedition RNG cursor must be a safe integer");
|
|
22
|
+
positiveInteger(config.tickMs, "tickMs");
|
|
23
|
+
positiveInteger(config.carryCap, "carryCap");
|
|
24
|
+
positiveInteger(config.maxTicksPerSettle ?? 10_000, "maxTicksPerSettle");
|
|
25
|
+
positiveInteger(config.logLimit ?? 200, "logLimit");
|
|
26
|
+
nonnegative(config.returnFraction, "returnFraction");
|
|
27
|
+
nonnegative(state.hp, "hp");
|
|
28
|
+
nonnegative(state.exposure, "exposure");
|
|
29
|
+
nonnegative(state.elapsedMs, "elapsedMs");
|
|
30
|
+
for (const count of Object.values(state.supplies))
|
|
31
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
32
|
+
throw new RangeError("Supply counts must be nonnegative safe integers");
|
|
33
|
+
}
|
|
34
|
+
function record(config, state, event, text) {
|
|
35
|
+
return { ...state, log: appendFeed(state.log, { ...event, text: text ?? config.describe?.(event, state) ?? "" }, { limit: config.logLimit ?? 200 }) };
|
|
36
|
+
}
|
|
37
|
+
function itemUnits(drops) {
|
|
38
|
+
let units = 0;
|
|
39
|
+
for (const drop of drops)
|
|
40
|
+
if (drop.item !== undefined)
|
|
41
|
+
units += nonnegative(drop.count, "Item count");
|
|
42
|
+
return nonnegative(units, "Carried item units");
|
|
43
|
+
}
|
|
44
|
+
function acceptLoot(cap, carried, drops) {
|
|
45
|
+
let remaining = Math.max(0, cap - itemUnits(carried));
|
|
46
|
+
const next = carried.map(drop => ({ ...drop }));
|
|
47
|
+
const accepted = [];
|
|
48
|
+
for (const drop of drops) {
|
|
49
|
+
const count = nonnegative(drop.count, "Drop count");
|
|
50
|
+
if ((drop.item === undefined) === (drop.currency === undefined))
|
|
51
|
+
throw new RangeError("Expedition drops need exactly one item or currency id");
|
|
52
|
+
if (drop.item !== undefined && !Number.isSafeInteger(count))
|
|
53
|
+
throw new RangeError("Item counts must be nonnegative safe integers");
|
|
54
|
+
const admitted = drop.item === undefined ? count : Math.min(count, remaining);
|
|
55
|
+
if (drop.item !== undefined)
|
|
56
|
+
remaining -= admitted;
|
|
57
|
+
if (admitted === 0)
|
|
58
|
+
continue;
|
|
59
|
+
const incoming = { ...drop, count: admitted };
|
|
60
|
+
accepted.push(incoming);
|
|
61
|
+
const existing = next.find(entry => drop.item !== undefined ? entry.item === drop.item : entry.currency === drop.currency && entry.item === undefined);
|
|
62
|
+
if (existing === undefined)
|
|
63
|
+
next.push({ ...incoming });
|
|
64
|
+
else
|
|
65
|
+
existing.count = nonnegative(existing.count + admitted, "Carried count");
|
|
66
|
+
}
|
|
67
|
+
return { carried: next, accepted };
|
|
68
|
+
}
|
|
69
|
+
function arrive(config, state, nowMs) {
|
|
70
|
+
if (state.status !== "returning" || state.returnUntilMs === null || nowMs < state.returnUntilMs)
|
|
71
|
+
return state;
|
|
72
|
+
const at = state.returnUntilMs;
|
|
73
|
+
return record(config, { ...state, status: "home", lastSettledMs: at }, { at, kind: "return" });
|
|
74
|
+
}
|
|
75
|
+
function beginReturn(config, state, at) {
|
|
76
|
+
const elapsedMs = Math.max(state.elapsedMs, at - state.startedAtMs);
|
|
77
|
+
const returnUntilMs = at + elapsedMs * config.returnFraction;
|
|
78
|
+
if (!Number.isFinite(returnUntilMs))
|
|
79
|
+
throw new RangeError("Expedition return deadline overflowed");
|
|
80
|
+
return arrive(config, record(config, { ...state, status: "returning", elapsedMs, returnUntilMs, lastSettledMs: at }, { at, kind: "recall" }), at);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Dispatch plain expedition state with a persisted seeded cursor; no clock, scheduler or storage is owned.
|
|
84
|
+
*
|
|
85
|
+
* @capability expedition-dispatch dispatch a saveable timed away mission with finite supplies and a seeded RNG cursor
|
|
86
|
+
*/
|
|
87
|
+
export function dispatchExpedition(input) {
|
|
88
|
+
if (!Number.isFinite(input.startAtMs))
|
|
89
|
+
throw new RangeError("startAtMs must be finite");
|
|
90
|
+
nonnegative(input.hp, "hp");
|
|
91
|
+
nonnegative(input.exposure ?? 0, "exposure");
|
|
92
|
+
const supplies = { ...input.supplies };
|
|
93
|
+
for (const count of Object.values(supplies))
|
|
94
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
95
|
+
throw new RangeError("Supply counts must be nonnegative safe integers");
|
|
96
|
+
return { id: input.id, status: input.hp === 0 ? "dead" : "exploring", startedAtMs: input.startAtMs, lastSettledMs: input.startAtMs,
|
|
97
|
+
elapsedMs: 0, returnUntilMs: null, hp: input.hp, exposure: input.exposure ?? 0, supplies, carried: [], log: [], seed: input.seed, cursor: randomSeedFrom(hashString(input.seed)) };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Replay due event ticks using the existing stepped catch-up primitive. Order is loot → hazard → ordered
|
|
101
|
+
* supplies → death → capacity recall. Unprocessed ticks retain their frontier; returning never rolls or
|
|
102
|
+
* takes damage. Auto-recall uses the triggering tick's time, including offline arrival in the same call.
|
|
103
|
+
*
|
|
104
|
+
* @capability expedition-settle settle bounded offline expedition ticks without losing supplies rewards or RNG continuity
|
|
105
|
+
*/
|
|
106
|
+
export function settleExpedition(config, state, nowMs) {
|
|
107
|
+
validate(config, state, nowMs);
|
|
108
|
+
if (nowMs < state.lastSettledMs || (state.status === "exploring" && nowMs < state.startedAtMs + state.elapsedMs) || state.status === "dead" || state.status === "home")
|
|
109
|
+
return state;
|
|
110
|
+
if (state.status === "returning")
|
|
111
|
+
return arrive(config, state, nowMs);
|
|
112
|
+
let next = state;
|
|
113
|
+
const step = config.randomStep ?? stepRandomSeed;
|
|
114
|
+
const window = accrueSince(state.lastSettledMs, nowMs);
|
|
115
|
+
nonnegative(window.elapsedMs, "Catch-up duration");
|
|
116
|
+
// Normalize to whole one-unit ticks so fractional-second division cannot lose a due millisecond.
|
|
117
|
+
const ticks = Math.floor(window.elapsedMs / config.tickMs);
|
|
118
|
+
steppedCatchUp(ticks, 1, () => {
|
|
119
|
+
if (next.status !== "exploring")
|
|
120
|
+
return;
|
|
121
|
+
const at = next.lastSettledMs + config.tickMs;
|
|
122
|
+
next = { ...next, lastSettledMs: at, elapsedMs: at - next.startedAtMs };
|
|
123
|
+
let cursor = next.cursor;
|
|
124
|
+
const rng = () => {
|
|
125
|
+
const [value, following] = step(cursor);
|
|
126
|
+
if (!Number.isFinite(value) || value < 0 || value >= 1 || !Number.isSafeInteger(following))
|
|
127
|
+
throw new RangeError("Expedition RNG must return a [0,1) value and safe integer cursor");
|
|
128
|
+
cursor = following;
|
|
129
|
+
return value;
|
|
130
|
+
};
|
|
131
|
+
const resolution = config.loot.resolve({ ctx: config.lootCtx(next), rng, seed: next.seed });
|
|
132
|
+
const accepted = acceptLoot(config.carryCap, next.carried, resolution.drops);
|
|
133
|
+
next = { ...next, carried: accepted.carried };
|
|
134
|
+
if (resolution.drops.length > 0)
|
|
135
|
+
next = record(config, next, { at, kind: "loot", drops: accepted.accepted, loot: resolution });
|
|
136
|
+
const hazard = config.hazard?.(next, rng) ?? {};
|
|
137
|
+
const exposure = nonnegative(next.exposure + nonnegative(hazard.exposure ?? 0, "Hazard exposure"), "Exposure");
|
|
138
|
+
const hp = Math.min(nonnegative(config.maxHp({ ...next, exposure }), "maxHp"), Math.max(0, next.hp - nonnegative(hazard.damage ?? 0, "Hazard damage")));
|
|
139
|
+
const hpDelta = hp - next.hp, exposureDelta = exposure - next.exposure;
|
|
140
|
+
next = { ...next, hp, exposure };
|
|
141
|
+
if (hpDelta !== 0 || exposureDelta !== 0)
|
|
142
|
+
next = record(config, next, { at, kind: "hazard", hpDelta, exposureDelta });
|
|
143
|
+
for (const notice of hazard.notices ?? [])
|
|
144
|
+
next = record(config, next, { at, kind: notice.kind }, notice.text);
|
|
145
|
+
for (const rule of config.supplies ?? []) {
|
|
146
|
+
const count = Object.hasOwn(next.supplies, rule.key) ? next.supplies[rule.key] : 0;
|
|
147
|
+
const restoreHp = nonnegative(rule.restoreHp ?? 0, "Supply restoration"), clearExposure = nonnegative(rule.clearExposure ?? 0, "Supply cleansing");
|
|
148
|
+
if (count <= 0 || !rule.when(next))
|
|
149
|
+
continue;
|
|
150
|
+
const exposure = Math.max(0, next.exposure - clearExposure);
|
|
151
|
+
const hp = Math.min(nonnegative(config.maxHp({ ...next, exposure }), "maxHp"), nonnegative(next.hp + restoreHp, "Restored hp"));
|
|
152
|
+
if (hp === next.hp && exposure === next.exposure)
|
|
153
|
+
continue;
|
|
154
|
+
const hpDelta = hp - next.hp, exposureDelta = exposure - next.exposure;
|
|
155
|
+
next = { ...next, hp, exposure, supplies: { ...next.supplies, [rule.key]: count - 1 } };
|
|
156
|
+
next = record(config, next, { at, kind: "supply", supply: rule.key, hpDelta, exposureDelta });
|
|
157
|
+
}
|
|
158
|
+
next = { ...next, cursor };
|
|
159
|
+
if (next.hp <= 0)
|
|
160
|
+
next = record(config, { ...next, status: "dead" }, { at, kind: "death" });
|
|
161
|
+
else if (itemUnits(next.carried) >= config.carryCap)
|
|
162
|
+
next = beginReturn(config, next, at);
|
|
163
|
+
}, config.maxTicksPerSettle ?? 10_000);
|
|
164
|
+
if (next.status === "exploring" && nowMs - next.lastSettledMs < config.tickMs) {
|
|
165
|
+
const elapsedMs = Math.max(next.elapsedMs, nowMs - next.startedAtMs);
|
|
166
|
+
nonnegative(elapsedMs, "Exploring duration");
|
|
167
|
+
if (elapsedMs !== next.elapsedMs)
|
|
168
|
+
next = { ...next, elapsedMs };
|
|
169
|
+
if (itemUnits(next.carried) >= config.carryCap)
|
|
170
|
+
next = beginReturn(config, next, nowMs);
|
|
171
|
+
}
|
|
172
|
+
return arrive(config, next, nowMs);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Settle first, then recall safely. If the catch-up budget leaves due exploring ticks, return that state
|
|
176
|
+
* without recalling; repeat at the same target until the frontier catches up. Dead state never revives.
|
|
177
|
+
*
|
|
178
|
+
* @capability expedition-recall begin a safe fractional-time return after settling the pending expedition frontier
|
|
179
|
+
*/
|
|
180
|
+
export function recallExpedition(config, state, nowMs) {
|
|
181
|
+
const next = settleExpedition(config, state, nowMs);
|
|
182
|
+
return next.status === "exploring" && nowMs >= next.startedAtMs + next.elapsedMs && nowMs >= next.lastSettledMs && nowMs - next.lastSettledMs < config.tickMs ? beginReturn(config, next, nowMs) : next;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Settle first, then complete a live return instantly. Caller owns payment/authorization; pending ticks
|
|
186
|
+
* remain unsettled when the budget is exhausted, and dead/home state is never changed.
|
|
187
|
+
*
|
|
188
|
+
* @capability expedition-return complete an authorized instant return without skipping unsettled ticks or reviving death
|
|
189
|
+
*/
|
|
190
|
+
export function returnExpeditionNow(config, state, nowMs) {
|
|
191
|
+
const next = settleExpedition(config, state, nowMs);
|
|
192
|
+
if (next.status === "dead" || next.status === "home" || nowMs < next.lastSettledMs || (next.status === "exploring" && nowMs < next.startedAtMs + next.elapsedMs) || (next.status === "exploring" && nowMs - next.lastSettledMs >= config.tickMs))
|
|
193
|
+
return next;
|
|
194
|
+
return record(config, { ...next, status: "home", returnUntilMs: nowMs, lastSettledMs: nowMs }, { at: nowMs, kind: "return" });
|
|
195
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One assigned worker's caller-resolved contribution; stat names and modifiers stay caller-owned. */
|
|
2
|
+
export interface StationWorker {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly stat: number;
|
|
5
|
+
}
|
|
6
|
+
/** Serializable staffing snapshot. The caller owns assignment, unique worker ids, and stable order. */
|
|
7
|
+
export interface StaffedStation {
|
|
8
|
+
readonly stationId: string;
|
|
9
|
+
/** Nonnegative whole slot count; fractional values round down and nonfinite values admit no workers. */
|
|
10
|
+
readonly slots: number;
|
|
11
|
+
/** Only the first `slots` entries contribute; overflow assignments are not reordered or mutated. */
|
|
12
|
+
readonly workers: readonly StationWorker[];
|
|
13
|
+
}
|
|
14
|
+
/** Caller-owned output tuning in production cycles per game-second. */
|
|
15
|
+
export interface StationRateTuning {
|
|
16
|
+
readonly base: number;
|
|
17
|
+
readonly perStat: number;
|
|
18
|
+
/** Applied to the whole rate, including base; defaults to 1 and clamps to 0…1. */
|
|
19
|
+
readonly efficiency?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve `(base + perStat * sum(admitted worker stats)) * efficiency` without allocation.
|
|
23
|
+
* Empty staffing retains base production. Negative/nonfinite contributions are zero; a nonfinite
|
|
24
|
+
* efficiency or overflowing result stops production with rate zero. Invalid workers still occupy
|
|
25
|
+
* their ordered slots. Assign the result to a `ProductionBuildingDef.rate` for `tickProduction`.
|
|
26
|
+
*
|
|
27
|
+
* @capability staffed-station-rate turn bounded caller-owned worker stats and efficiency into a production rate
|
|
28
|
+
*/
|
|
29
|
+
export declare function stationOutputRate(station: StaffedStation, tuning: StationRateTuning): number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function contribution(value) {
|
|
2
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Resolve `(base + perStat * sum(admitted worker stats)) * efficiency` without allocation.
|
|
6
|
+
* Empty staffing retains base production. Negative/nonfinite contributions are zero; a nonfinite
|
|
7
|
+
* efficiency or overflowing result stops production with rate zero. Invalid workers still occupy
|
|
8
|
+
* their ordered slots. Assign the result to a `ProductionBuildingDef.rate` for `tickProduction`.
|
|
9
|
+
*
|
|
10
|
+
* @capability staffed-station-rate turn bounded caller-owned worker stats and efficiency into a production rate
|
|
11
|
+
*/
|
|
12
|
+
export function stationOutputRate(station, tuning) {
|
|
13
|
+
const efficiency = Math.min(1, contribution(tuning.efficiency ?? 1));
|
|
14
|
+
if (efficiency === 0)
|
|
15
|
+
return 0;
|
|
16
|
+
const base = contribution(tuning.base);
|
|
17
|
+
const perStat = contribution(tuning.perStat);
|
|
18
|
+
if (perStat === 0)
|
|
19
|
+
return base * efficiency;
|
|
20
|
+
const slots = Number.isFinite(station.slots) ? Math.max(0, Math.floor(station.slots)) : 0;
|
|
21
|
+
const count = Math.min(slots, station.workers.length);
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (let index = 0; index < count; index += 1)
|
|
24
|
+
total += contribution(station.workers[index].stat);
|
|
25
|
+
const rate = (base + perStat * total) * efficiency;
|
|
26
|
+
return Number.isFinite(rate) ? rate : 0;
|
|
27
|
+
}
|