@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
package/dist/game/quest.js
CHANGED
|
@@ -7,6 +7,104 @@ import { clamp } from "../math/scalar.js";
|
|
|
7
7
|
export function createQuestJournal(deps) {
|
|
8
8
|
const catalog = new Map();
|
|
9
9
|
const users = new Map();
|
|
10
|
+
const turningIn = new Set();
|
|
11
|
+
const credits = new Map();
|
|
12
|
+
const activeUsers = new Map();
|
|
13
|
+
const userKills = new Map();
|
|
14
|
+
const userCollects = new Map();
|
|
15
|
+
const sharedKills = new Map();
|
|
16
|
+
const catalogOrder = new Map();
|
|
17
|
+
function compareCredit(a, b) {
|
|
18
|
+
return a.order - b.order || a.index - b.index;
|
|
19
|
+
}
|
|
20
|
+
function updateIndex(index, key, credit, add) {
|
|
21
|
+
const bucket = index.get(key);
|
|
22
|
+
if (!add) {
|
|
23
|
+
if (!bucket)
|
|
24
|
+
return;
|
|
25
|
+
const position = bucket.indexOf(credit);
|
|
26
|
+
if (position !== -1)
|
|
27
|
+
bucket.splice(position, 1);
|
|
28
|
+
if (bucket.length === 0)
|
|
29
|
+
index.delete(key);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!bucket) {
|
|
33
|
+
index.set(key, [credit]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let low = 0;
|
|
37
|
+
let high = bucket.length;
|
|
38
|
+
while (low < high) {
|
|
39
|
+
const middle = (low + high) >>> 1;
|
|
40
|
+
if (compareCredit(bucket[middle], credit) < 0)
|
|
41
|
+
low = middle + 1;
|
|
42
|
+
else
|
|
43
|
+
high = middle;
|
|
44
|
+
}
|
|
45
|
+
bucket.splice(low, 0, credit);
|
|
46
|
+
}
|
|
47
|
+
function updateUserIndex(userId, questId, add) {
|
|
48
|
+
for (const credit of credits.get(questId) ?? []) {
|
|
49
|
+
const indexes = credit.kind === "kill" ? userKills : userCollects;
|
|
50
|
+
let index = indexes.get(userId);
|
|
51
|
+
if (!index) {
|
|
52
|
+
if (!add)
|
|
53
|
+
continue;
|
|
54
|
+
index = new Map();
|
|
55
|
+
indexes.set(userId, index);
|
|
56
|
+
}
|
|
57
|
+
updateIndex(index, credit.key, credit, add);
|
|
58
|
+
if (index.size === 0)
|
|
59
|
+
indexes.delete(userId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function updateSharedIndex(questId, add) {
|
|
63
|
+
for (const credit of credits.get(questId) ?? []) {
|
|
64
|
+
if (credit.kind === "kill" && credit.shared) {
|
|
65
|
+
updateIndex(sharedKills, credit.key, credit, add);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function setActive(userId, questId, active) {
|
|
70
|
+
const members = activeUsers.get(questId);
|
|
71
|
+
if (active) {
|
|
72
|
+
if (members?.has(userId))
|
|
73
|
+
return;
|
|
74
|
+
if (members)
|
|
75
|
+
members.add(userId);
|
|
76
|
+
else {
|
|
77
|
+
activeUsers.set(questId, new Set([userId]));
|
|
78
|
+
updateSharedIndex(questId, true);
|
|
79
|
+
}
|
|
80
|
+
updateUserIndex(userId, questId, true);
|
|
81
|
+
}
|
|
82
|
+
else if (members?.delete(userId)) {
|
|
83
|
+
updateUserIndex(userId, questId, false);
|
|
84
|
+
if (members.size === 0) {
|
|
85
|
+
activeUsers.delete(questId);
|
|
86
|
+
updateSharedIndex(questId, false);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function hydrate(userId, data) {
|
|
91
|
+
for (const [questId, state] of users.get(userId) ?? []) {
|
|
92
|
+
if (state.status === "active")
|
|
93
|
+
setActive(userId, questId, false);
|
|
94
|
+
}
|
|
95
|
+
const quests = new Map();
|
|
96
|
+
for (const entry of data) {
|
|
97
|
+
quests.set(entry.questId, {
|
|
98
|
+
status: entry.status,
|
|
99
|
+
progress: new Map(Object.entries(entry.progress)),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
users.set(userId, quests);
|
|
103
|
+
for (const [questId, state] of quests) {
|
|
104
|
+
if (state.status === "active")
|
|
105
|
+
setActive(userId, questId, true);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
10
108
|
function requireUserQuests(userId) {
|
|
11
109
|
let quests = users.get(userId);
|
|
12
110
|
if (!quests) {
|
|
@@ -41,6 +139,7 @@ export function createQuestJournal(deps) {
|
|
|
41
139
|
if (denied !== null)
|
|
42
140
|
return denied;
|
|
43
141
|
requireUserQuests(userId).set(questId, { status: "active", progress: new Map() });
|
|
142
|
+
setActive(userId, questId, true);
|
|
44
143
|
deps.events.emit("quest.accepted", { userId, questId });
|
|
45
144
|
return null;
|
|
46
145
|
}
|
|
@@ -67,6 +166,8 @@ export function createQuestJournal(deps) {
|
|
|
67
166
|
if (state === undefined || state.status !== "active") {
|
|
68
167
|
return { reason: `quest "${questId}" is not active` };
|
|
69
168
|
}
|
|
169
|
+
if (turningIn.has(state))
|
|
170
|
+
return { reason: `quest "${questId}" is already turning in` };
|
|
70
171
|
for (const objective of def.objectives) {
|
|
71
172
|
if ((state.progress.get(objective.id) ?? 0) < objective.count) {
|
|
72
173
|
return { reason: `objective "${objective.id}" incomplete` };
|
|
@@ -75,20 +176,15 @@ export function createQuestJournal(deps) {
|
|
|
75
176
|
return null;
|
|
76
177
|
}
|
|
77
178
|
function applyRewards(userId, rewards) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
deps.rewards.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
for (const unlockId of rewards.unlocks ?? []) {
|
|
89
|
-
deps.rewards.grantUnlock(userId, unlockId);
|
|
90
|
-
}
|
|
91
|
-
return null;
|
|
179
|
+
return applyQuestRewards(rewards, {
|
|
180
|
+
grantItem: (inventoryId, itemId, count) => deps.rewards.grantItem(userId, inventoryId, itemId, count),
|
|
181
|
+
grantItems: deps.rewards.grantItems === undefined
|
|
182
|
+
? undefined
|
|
183
|
+
: (items) => deps.rewards.grantItems(userId, items),
|
|
184
|
+
grantXp: (amount) => deps.rewards.grantXp(userId, amount),
|
|
185
|
+
grantEconomy: (currencyId, amount) => deps.rewards.grantEconomy(userId, currencyId, amount),
|
|
186
|
+
grantUnlock: (unlockId) => deps.rewards.grantUnlock(userId, unlockId),
|
|
187
|
+
});
|
|
92
188
|
}
|
|
93
189
|
function turnIn(userId, questId) {
|
|
94
190
|
const denied = canTurnIn(userId, questId);
|
|
@@ -96,49 +192,95 @@ export function createQuestJournal(deps) {
|
|
|
96
192
|
return denied;
|
|
97
193
|
const def = catalog.get(questId);
|
|
98
194
|
const state = users.get(userId).get(questId);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
195
|
+
turningIn.add(state);
|
|
196
|
+
try {
|
|
197
|
+
if (def.rewards) {
|
|
198
|
+
const fail = applyRewards(userId, def.rewards);
|
|
199
|
+
if (fail !== null)
|
|
200
|
+
return fail;
|
|
201
|
+
}
|
|
202
|
+
state.status = "completed";
|
|
203
|
+
setActive(userId, questId, false);
|
|
204
|
+
deps.events.emit("quest.completed", { userId, questId });
|
|
205
|
+
for (const nextQuestId of def.rewards?.quests ?? []) {
|
|
206
|
+
if (canAccept(userId, nextQuestId) === null)
|
|
207
|
+
accept(userId, nextQuestId);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
103
210
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (const nextQuestId of def.rewards?.quests ?? []) {
|
|
107
|
-
if (canAccept(userId, nextQuestId) === null)
|
|
108
|
-
accept(userId, nextQuestId);
|
|
211
|
+
finally {
|
|
212
|
+
turningIn.delete(state);
|
|
109
213
|
}
|
|
110
|
-
return null;
|
|
111
214
|
}
|
|
112
215
|
function creditKill(killerUserId, catalogId) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
216
|
+
const direct = userKills.get(killerUserId)?.get(catalogId) ?? [];
|
|
217
|
+
const shared = deps.partyMembersNear ? sharedKills.get(catalogId) ?? [] : [];
|
|
218
|
+
const candidates = [];
|
|
219
|
+
let a = 0;
|
|
220
|
+
let b = 0;
|
|
221
|
+
while (a < direct.length || b < shared.length) {
|
|
222
|
+
const left = direct[a];
|
|
223
|
+
const right = shared[b];
|
|
224
|
+
if (left && right && left === right) {
|
|
225
|
+
candidates.push(left);
|
|
226
|
+
a++;
|
|
227
|
+
b++;
|
|
228
|
+
}
|
|
229
|
+
else if (left && (!right || compareCredit(left, right) < 0)) {
|
|
230
|
+
candidates.push(left);
|
|
231
|
+
a++;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
candidates.push(right);
|
|
235
|
+
b++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const { questId, objective } of candidates) {
|
|
239
|
+
const recipients = new Set([killerUserId]);
|
|
240
|
+
if (objective.partyShare?.credit === "all" && deps.partyMembersNear) {
|
|
241
|
+
for (const member of deps.partyMembersNear(killerUserId, objective.partyShare.radius)) {
|
|
242
|
+
recipients.add(member);
|
|
122
243
|
}
|
|
123
|
-
for (const userId of recipients)
|
|
124
|
-
progress(userId, def.id, objective.id, 1);
|
|
125
244
|
}
|
|
245
|
+
for (const userId of recipients)
|
|
246
|
+
progress(userId, questId, objective.id, 1);
|
|
126
247
|
}
|
|
127
248
|
}
|
|
128
249
|
function creditCollect(userId, itemId, count) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
continue;
|
|
133
|
-
progress(userId, def.id, objective.id, count);
|
|
134
|
-
}
|
|
250
|
+
const candidates = userCollects.get(userId)?.get(itemId)?.slice() ?? [];
|
|
251
|
+
for (const { questId, objective } of candidates) {
|
|
252
|
+
progress(userId, questId, objective.id, count);
|
|
135
253
|
}
|
|
136
254
|
}
|
|
137
255
|
return {
|
|
138
256
|
register(defs) {
|
|
139
257
|
const entries = Array.isArray(defs) ? defs : Object.values(defs);
|
|
140
|
-
for (const def of entries)
|
|
258
|
+
for (const def of entries) {
|
|
259
|
+
const members = activeUsers.get(def.id);
|
|
260
|
+
if (members) {
|
|
261
|
+
for (const userId of members)
|
|
262
|
+
updateUserIndex(userId, def.id, false);
|
|
263
|
+
updateSharedIndex(def.id, false);
|
|
264
|
+
}
|
|
265
|
+
if (!catalogOrder.has(def.id))
|
|
266
|
+
catalogOrder.set(def.id, catalogOrder.size);
|
|
141
267
|
catalog.set(def.id, def);
|
|
268
|
+
credits.set(def.id, def.objectives.flatMap((objective, index) => {
|
|
269
|
+
const kind = objective.kind;
|
|
270
|
+
if (kind !== "kill" && kind !== "collect")
|
|
271
|
+
return [];
|
|
272
|
+
const key = kind === "kill" ? objective.target : objective.item;
|
|
273
|
+
if (key === undefined)
|
|
274
|
+
return [];
|
|
275
|
+
return [{ questId: def.id, objective, order: catalogOrder.get(def.id), index,
|
|
276
|
+
kind, key, shared: objective.partyShare?.credit === "all" }];
|
|
277
|
+
}));
|
|
278
|
+
if (members) {
|
|
279
|
+
for (const userId of members)
|
|
280
|
+
updateUserIndex(userId, def.id, true);
|
|
281
|
+
updateSharedIndex(def.id, true);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
142
284
|
},
|
|
143
285
|
has(questId) {
|
|
144
286
|
return catalog.has(questId);
|
|
@@ -147,8 +289,10 @@ export function createQuestJournal(deps) {
|
|
|
147
289
|
accept,
|
|
148
290
|
abandon(userId, questId) {
|
|
149
291
|
const quests = users.get(userId);
|
|
150
|
-
if (quests?.get(questId)?.status === "active")
|
|
292
|
+
if (quests?.get(questId)?.status === "active") {
|
|
151
293
|
quests.delete(questId);
|
|
294
|
+
setActive(userId, questId, false);
|
|
295
|
+
}
|
|
152
296
|
},
|
|
153
297
|
progress,
|
|
154
298
|
canTurnIn,
|
|
@@ -167,6 +311,7 @@ export function createQuestJournal(deps) {
|
|
|
167
311
|
status: completed ? "completed" : "active",
|
|
168
312
|
progress: progressMap,
|
|
169
313
|
});
|
|
314
|
+
setActive(userId, questId, !completed);
|
|
170
315
|
if (completed)
|
|
171
316
|
deps.events.emit("quest.completed", { userId, questId });
|
|
172
317
|
else
|
|
@@ -174,6 +319,7 @@ export function createQuestJournal(deps) {
|
|
|
174
319
|
},
|
|
175
320
|
revoke(userId, questId) {
|
|
176
321
|
users.get(userId)?.delete(questId);
|
|
322
|
+
setActive(userId, questId, false);
|
|
177
323
|
},
|
|
178
324
|
list(userId) {
|
|
179
325
|
const quests = users.get(userId);
|
|
@@ -223,16 +369,7 @@ export function createQuestJournal(deps) {
|
|
|
223
369
|
progress: Object.fromEntries(state.progress),
|
|
224
370
|
}));
|
|
225
371
|
},
|
|
226
|
-
hydrate
|
|
227
|
-
const quests = new Map();
|
|
228
|
-
for (const entry of data) {
|
|
229
|
-
quests.set(entry.questId, {
|
|
230
|
-
status: entry.status,
|
|
231
|
-
progress: new Map(Object.entries(entry.progress)),
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
users.set(userId, quests);
|
|
235
|
-
},
|
|
372
|
+
hydrate,
|
|
236
373
|
snapshotAll() {
|
|
237
374
|
const out = {};
|
|
238
375
|
for (const [userId, quests] of users) {
|
|
@@ -246,15 +383,12 @@ export function createQuestJournal(deps) {
|
|
|
246
383
|
},
|
|
247
384
|
hydrateAll(data) {
|
|
248
385
|
users.clear();
|
|
386
|
+
activeUsers.clear();
|
|
387
|
+
userKills.clear();
|
|
388
|
+
userCollects.clear();
|
|
389
|
+
sharedKills.clear();
|
|
249
390
|
for (const [userId, entries] of Object.entries(data)) {
|
|
250
|
-
|
|
251
|
-
for (const entry of entries) {
|
|
252
|
-
quests.set(entry.questId, {
|
|
253
|
-
status: entry.status,
|
|
254
|
-
progress: new Map(Object.entries(entry.progress)),
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
users.set(userId, quests);
|
|
391
|
+
hydrate(userId, entries);
|
|
258
392
|
}
|
|
259
393
|
},
|
|
260
394
|
};
|
|
@@ -290,9 +424,21 @@ export function describeTrackedQuest(def, instance, label = defaultObjectiveLabe
|
|
|
290
424
|
}),
|
|
291
425
|
};
|
|
292
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Apply items before XP, currency, and unlocks. Multiple items require an all-or-reject `grantItems`
|
|
429
|
+
* callback; legacy single-item appliers remain supported. Rejected callbacks must make no writes.
|
|
430
|
+
* Arbitrary callback side effects or thrown exceptions cannot be rolled back by this helper.
|
|
431
|
+
*/
|
|
293
432
|
export function applyQuestRewards(rewards, appliers) {
|
|
294
|
-
|
|
295
|
-
|
|
433
|
+
const items = rewards.items ?? [];
|
|
434
|
+
if (items.length > 1 && appliers.grantItems === undefined) {
|
|
435
|
+
return { reason: "multiple quest item rewards require grantItems" };
|
|
436
|
+
}
|
|
437
|
+
if (items.length > 0) {
|
|
438
|
+
const entry = items[0];
|
|
439
|
+
const fail = appliers.grantItems !== undefined
|
|
440
|
+
? appliers.grantItems(items)
|
|
441
|
+
: appliers.grantItem?.(entry.inventory, entry.item, entry.count);
|
|
296
442
|
if (fail != null && typeof fail === "object" && "reason" in fail)
|
|
297
443
|
return fail;
|
|
298
444
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { QuestDef, QuestObjective, QuestRewards } from "./quest.js";
|
|
2
|
+
/** Readonly authoring input; mutable QuestDef catalogs also satisfy this shape. */
|
|
3
|
+
export type QuestCatalogDefinition = Omit<Readonly<QuestDef>, "requires" | "objectives" | "rewards"> & {
|
|
4
|
+
requires?: readonly string[];
|
|
5
|
+
objectives: readonly Readonly<QuestObjective>[];
|
|
6
|
+
rewards?: Omit<Readonly<QuestRewards>, "items" | "unlocks" | "quests"> & {
|
|
7
|
+
items?: readonly Readonly<NonNullable<QuestRewards["items"]>[number]>[];
|
|
8
|
+
unlocks?: readonly string[];
|
|
9
|
+
quests?: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/** Caller-owned reference namespaces; objective kinds remain unconstrained. */
|
|
13
|
+
export type QuestCatalogReferenceKind = "item" | "target" | "inventory" | "currency";
|
|
14
|
+
/** A located structural issue; path is a JSON Pointer into the supplied catalog. */
|
|
15
|
+
export interface QuestCatalogIssue {
|
|
16
|
+
code: "duplicate-quest" | "duplicate-objective" | "missing-requirement" | "missing-follow-up" | "missing-external-start" | "unknown-reference" | "invalid-count" | "invalid-amount" | "invalid-radius" | "blocked-prerequisites" | "dependency-cycle";
|
|
17
|
+
severity: "error" | "warning";
|
|
18
|
+
message: string;
|
|
19
|
+
path: string;
|
|
20
|
+
questId?: string;
|
|
21
|
+
objectiveId?: string;
|
|
22
|
+
referenceId?: string;
|
|
23
|
+
relatedQuestIds?: string[];
|
|
24
|
+
requirementIds?: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Declarations and optional lookups for a game's own authoring rules. */
|
|
27
|
+
export interface QuestCatalogValidationOptions {
|
|
28
|
+
/** Unlock ids that non-quest systems may grant, including ids shared with quests. */
|
|
29
|
+
externalUnlocks?: Iterable<string>;
|
|
30
|
+
/** Quests game systems may grant active, bypassing their acceptance prerequisites. */
|
|
31
|
+
externallyStartedQuests?: Iterable<string>;
|
|
32
|
+
/** Check present references against caller catalogs; omitted means unchecked. */
|
|
33
|
+
hasReference?(kind: QuestCatalogReferenceKind, id: string, context: {
|
|
34
|
+
quest: QuestCatalogDefinition;
|
|
35
|
+
objective?: Readonly<QuestObjective>;
|
|
36
|
+
}): boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate catalog ids, references, quantities, and potential prerequisite progression without mutation.
|
|
40
|
+
* Requirements are completed quest ids OR unlock ids; all requirements of a quest must be met.
|
|
41
|
+
* Any eligible quest may be accepted directly. External unlocks and granted quest starts are possible
|
|
42
|
+
* entry points, not claims about their timing. Blocked progression/cycles are warnings: game systems
|
|
43
|
+
* can bypass prerequisites. Topology uses the last duplicate, matching journal registration.
|
|
44
|
+
* Traversal is iterative and linear in definitions, requirements, and their producer edges.
|
|
45
|
+
* Objective and item counts may be fractional or zero; negative/nonfinite counts are invalid.
|
|
46
|
+
* This assumes objectives can be completed and does not evaluate narrative, chronology, or balance.
|
|
47
|
+
*
|
|
48
|
+
* @capability quest-catalog-validation diagnose authored quest ids, prerequisite and reward references, quantities, and blocked dependencies with caller-declared external unlocks
|
|
49
|
+
*/
|
|
50
|
+
export declare function validateQuestCatalog(catalog: readonly QuestCatalogDefinition[] | Readonly<Record<string, QuestCatalogDefinition>>, options?: QuestCatalogValidationOptions): QuestCatalogIssue[];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
function pointerKey(key) {
|
|
2
|
+
return key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Validate catalog ids, references, quantities, and potential prerequisite progression without mutation.
|
|
6
|
+
* Requirements are completed quest ids OR unlock ids; all requirements of a quest must be met.
|
|
7
|
+
* Any eligible quest may be accepted directly. External unlocks and granted quest starts are possible
|
|
8
|
+
* entry points, not claims about their timing. Blocked progression/cycles are warnings: game systems
|
|
9
|
+
* can bypass prerequisites. Topology uses the last duplicate, matching journal registration.
|
|
10
|
+
* Traversal is iterative and linear in definitions, requirements, and their producer edges.
|
|
11
|
+
* Objective and item counts may be fractional or zero; negative/nonfinite counts are invalid.
|
|
12
|
+
* This assumes objectives can be completed and does not evaluate narrative, chronology, or balance.
|
|
13
|
+
*
|
|
14
|
+
* @capability quest-catalog-validation diagnose authored quest ids, prerequisite and reward references, quantities, and blocked dependencies with caller-declared external unlocks
|
|
15
|
+
*/
|
|
16
|
+
export function validateQuestCatalog(catalog, options = {}) {
|
|
17
|
+
const entries = Array.isArray(catalog)
|
|
18
|
+
? catalog.map((quest, index) => ({ quest, path: `/${index}` }))
|
|
19
|
+
: Object.entries(catalog).map(([key, quest]) => ({ quest, path: `/${pointerKey(key)}` }));
|
|
20
|
+
const issues = [];
|
|
21
|
+
const quests = new Map();
|
|
22
|
+
const producers = new Map();
|
|
23
|
+
const externalUnlocks = new Set(options.externalUnlocks);
|
|
24
|
+
const externalStarts = new Set(options.externallyStartedQuests);
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const { quest, path } = entry;
|
|
27
|
+
if (quests.has(quest.id)) {
|
|
28
|
+
issues.push({ code: "duplicate-quest", severity: "error", questId: quest.id, path: `${path}/id`,
|
|
29
|
+
message: `Duplicate quest "${quest.id}"; journal registration replaces the earlier definition.` });
|
|
30
|
+
}
|
|
31
|
+
quests.set(quest.id, entry);
|
|
32
|
+
}
|
|
33
|
+
for (const { quest } of quests.values()) {
|
|
34
|
+
for (const token of new Set([quest.id, ...(quest.rewards?.unlocks ?? [])])) {
|
|
35
|
+
let sources = producers.get(token);
|
|
36
|
+
if (sources === undefined)
|
|
37
|
+
producers.set(token, sources = new Set());
|
|
38
|
+
sources.add(quest.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const { quest, path } of entries) {
|
|
42
|
+
function issue(code, field, message, detail = {}) {
|
|
43
|
+
issues.push({ code, severity: "error", path: `${path}/${field}`, questId: quest.id, message, ...detail });
|
|
44
|
+
}
|
|
45
|
+
function reference(kind, id, field, objective) {
|
|
46
|
+
if (id !== undefined && options.hasReference?.(kind, id, { quest, objective }) === false) {
|
|
47
|
+
issue("unknown-reference", field, `Unknown ${kind} reference "${id}".`, { referenceId: id, objectiveId: objective?.id });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const [index, id] of (quest.requires ?? []).entries()) {
|
|
51
|
+
if (!producers.has(id) && !externalUnlocks.has(id)) {
|
|
52
|
+
issue("missing-requirement", `requires/${index}`, `Requirement "${id}" is neither a catalog quest nor a declared unlock.`, { referenceId: id });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const objectiveIds = new Set();
|
|
56
|
+
for (const [index, objective] of quest.objectives.entries()) {
|
|
57
|
+
const field = `objectives/${index}`;
|
|
58
|
+
if (objectiveIds.has(objective.id)) {
|
|
59
|
+
issue("duplicate-objective", `${field}/id`, `Duplicate objective "${objective.id}" in quest "${quest.id}".`, { objectiveId: objective.id });
|
|
60
|
+
}
|
|
61
|
+
objectiveIds.add(objective.id);
|
|
62
|
+
if (!Number.isFinite(objective.count) || objective.count < 0) {
|
|
63
|
+
issue("invalid-count", `${field}/count`, "Objective count must be finite and nonnegative.", { objectiveId: objective.id });
|
|
64
|
+
}
|
|
65
|
+
if (objective.partyShare && (!Number.isFinite(objective.partyShare.radius) || objective.partyShare.radius < 0)) {
|
|
66
|
+
issue("invalid-radius", `${field}/partyShare/radius`, "Party share radius must be finite and nonnegative.", { objectiveId: objective.id });
|
|
67
|
+
}
|
|
68
|
+
reference("item", objective.item, `${field}/item`, objective);
|
|
69
|
+
reference("target", objective.target, `${field}/target`, objective);
|
|
70
|
+
}
|
|
71
|
+
for (const [index, id] of (quest.rewards?.quests ?? []).entries()) {
|
|
72
|
+
if (!quests.has(id)) {
|
|
73
|
+
issue("missing-follow-up", `rewards/quests/${index}`, `Reward starts unknown quest "${id}".`, { referenceId: id });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [index, reward] of (quest.rewards?.items ?? []).entries()) {
|
|
77
|
+
const field = `rewards/items/${index}`;
|
|
78
|
+
if (!Number.isFinite(reward.count) || reward.count < 0) {
|
|
79
|
+
issue("invalid-count", `${field}/count`, "Item reward count must be finite and nonnegative.");
|
|
80
|
+
}
|
|
81
|
+
reference("item", reward.item, `${field}/item`);
|
|
82
|
+
reference("inventory", reward.inventory, `${field}/inventory`);
|
|
83
|
+
}
|
|
84
|
+
if (quest.rewards?.xp && !Number.isFinite(quest.rewards.xp.amount)) {
|
|
85
|
+
issue("invalid-amount", "rewards/xp/amount", "XP reward amount must be finite.");
|
|
86
|
+
}
|
|
87
|
+
for (const [id, amount] of Object.entries(quest.rewards?.economy ?? {})) {
|
|
88
|
+
const field = `rewards/economy/${pointerKey(id)}`;
|
|
89
|
+
if (!Number.isFinite(amount))
|
|
90
|
+
issue("invalid-amount", field, "Currency reward amount must be finite.");
|
|
91
|
+
reference("currency", id, field);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const id of externalStarts) {
|
|
95
|
+
if (!quests.has(id))
|
|
96
|
+
issues.push({ code: "missing-external-start", severity: "error", path: "",
|
|
97
|
+
referenceId: id, message: `Externally started quest "${id}" does not exist in the catalog.` });
|
|
98
|
+
}
|
|
99
|
+
const available = new Set(externalUnlocks);
|
|
100
|
+
const pending = new Map();
|
|
101
|
+
const dependents = new Map();
|
|
102
|
+
const ready = [];
|
|
103
|
+
for (const { quest } of quests.values()) {
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (const token of new Set(quest.requires ?? [])) {
|
|
106
|
+
if (available.has(token))
|
|
107
|
+
continue;
|
|
108
|
+
count++;
|
|
109
|
+
let waiting = dependents.get(token);
|
|
110
|
+
if (waiting === undefined)
|
|
111
|
+
dependents.set(token, waiting = []);
|
|
112
|
+
waiting.push(quest.id);
|
|
113
|
+
}
|
|
114
|
+
pending.set(quest.id, count);
|
|
115
|
+
if (count === 0 || externalStarts.has(quest.id))
|
|
116
|
+
ready.push(quest.id);
|
|
117
|
+
}
|
|
118
|
+
const completed = new Set();
|
|
119
|
+
for (let head = 0; head < ready.length; head++) {
|
|
120
|
+
const id = ready[head];
|
|
121
|
+
if (completed.has(id))
|
|
122
|
+
continue;
|
|
123
|
+
completed.add(id);
|
|
124
|
+
const quest = quests.get(id).quest;
|
|
125
|
+
for (const token of [id, ...(quest.rewards?.unlocks ?? [])]) {
|
|
126
|
+
if (available.has(token))
|
|
127
|
+
continue;
|
|
128
|
+
available.add(token);
|
|
129
|
+
for (const next of dependents.get(token) ?? []) {
|
|
130
|
+
const remaining = pending.get(next) - 1;
|
|
131
|
+
pending.set(next, remaining);
|
|
132
|
+
if (remaining === 0)
|
|
133
|
+
ready.push(next);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const graph = new Map();
|
|
138
|
+
const reverse = new Map();
|
|
139
|
+
for (const { quest, path } of quests.values()) {
|
|
140
|
+
if (completed.has(quest.id))
|
|
141
|
+
continue;
|
|
142
|
+
const unmet = [...new Set(quest.requires ?? [])].filter((id) => !available.has(id));
|
|
143
|
+
issues.push({ code: "blocked-prerequisites", severity: "warning", questId: quest.id,
|
|
144
|
+
path: `${path}/requires`, requirementIds: unmet,
|
|
145
|
+
message: `Quest "${quest.id}" has no prerequisite route from declared entry points; external grants may bypass it.` });
|
|
146
|
+
const dependencies = new Set();
|
|
147
|
+
for (const token of unmet) {
|
|
148
|
+
for (const producer of producers.get(token) ?? []) {
|
|
149
|
+
if (!completed.has(producer))
|
|
150
|
+
dependencies.add(producer);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
graph.set(quest.id, [...dependencies]);
|
|
154
|
+
for (const id of dependencies) {
|
|
155
|
+
let incoming = reverse.get(id);
|
|
156
|
+
if (incoming === undefined)
|
|
157
|
+
reverse.set(id, incoming = []);
|
|
158
|
+
incoming.push(quest.id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const visited = new Set();
|
|
162
|
+
const order = [];
|
|
163
|
+
for (const root of graph.keys()) {
|
|
164
|
+
if (visited.has(root))
|
|
165
|
+
continue;
|
|
166
|
+
visited.add(root);
|
|
167
|
+
const stack = [{ id: root, index: 0 }];
|
|
168
|
+
while (stack.length > 0) {
|
|
169
|
+
const frame = stack[stack.length - 1];
|
|
170
|
+
const neighbors = graph.get(frame.id);
|
|
171
|
+
if (frame.index === neighbors.length) {
|
|
172
|
+
order.push(frame.id);
|
|
173
|
+
stack.pop();
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const next = neighbors[frame.index++];
|
|
177
|
+
if (visited.has(next))
|
|
178
|
+
continue;
|
|
179
|
+
visited.add(next);
|
|
180
|
+
stack.push({ id: next, index: 0 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
visited.clear();
|
|
184
|
+
for (let index = order.length - 1; index >= 0; index--) {
|
|
185
|
+
const root = order[index];
|
|
186
|
+
if (visited.has(root))
|
|
187
|
+
continue;
|
|
188
|
+
const component = [root];
|
|
189
|
+
visited.add(root);
|
|
190
|
+
for (let head = 0; head < component.length; head++) {
|
|
191
|
+
for (const next of reverse.get(component[head]) ?? []) {
|
|
192
|
+
if (visited.has(next))
|
|
193
|
+
continue;
|
|
194
|
+
visited.add(next);
|
|
195
|
+
component.push(next);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (component.length > 1 || graph.get(root).includes(root)) {
|
|
199
|
+
issues.push({ code: "dependency-cycle", severity: "warning", questId: root,
|
|
200
|
+
path: `${quests.get(root).path}/requires`, relatedQuestIds: component,
|
|
201
|
+
message: "Blocked prerequisite producers form a cycle; review external grants or dependency references." });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
package/dist/game/saveStore.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type KeyValueStorage } from "./keyValueStore.js";
|
|
1
|
+
import { type KeyValueStorage, type KeyValueStorageErrorPolicy } from "./keyValueStore.js";
|
|
2
2
|
/**
|
|
3
3
|
* The one async storage seam a save store persists through. Every backend
|
|
4
4
|
* satisfies this same three-method shape — the browser's `localStorage`
|
|
@@ -16,17 +16,18 @@ export interface SaveBackend {
|
|
|
16
16
|
/**
|
|
17
17
|
* A {@link SaveBackend} over a synchronous {@link KeyValueStorage} — the
|
|
18
18
|
* browser's `localStorage` by default (offline, on-device saves), a test stub,
|
|
19
|
-
* or `null` for
|
|
20
|
-
*
|
|
19
|
+
* or `null` for a no-op backend. Default fallback ignores failures; `errorMode: "throw"`
|
|
20
|
+
* rejects failed operations and unavailable storage, so SaveStore status/onError can report them.
|
|
21
|
+
* Use {@link memorySaveBackend} for deliberate ephemeral persistence.
|
|
21
22
|
*
|
|
22
23
|
* @capability local-save persist a game to on-device localStorage (offline)
|
|
23
24
|
*/
|
|
24
|
-
export declare function localSaveBackend(storage?: KeyValueStorage | null): SaveBackend;
|
|
25
|
+
export declare function localSaveBackend(storage?: KeyValueStorage | null, policy?: KeyValueStorageErrorPolicy): SaveBackend;
|
|
25
26
|
/** A memory-only {@link SaveBackend} — saves survive a reload only within the same session. For tests, SSR, or a "no persistence" mode that still exercises the same save code path. */
|
|
26
27
|
export declare function memorySaveBackend(): SaveBackend;
|
|
27
28
|
/** Adopt any async `read`/`write`/`remove` trio as a {@link SaveBackend} — the seam for cloud saves backed by a database, an HTTP endpoint, or Convex (see `@jgengine/convex/convexSaveBackend`). Reads/writes may reject; the save store surfaces the failure as `"error"` status instead of throwing. */
|
|
28
29
|
export declare function remoteSaveBackend(backend: SaveBackend): SaveBackend;
|
|
29
|
-
/** Lifecycle of the last save/load — drive a "Saving…"/"Saved" indicator or a loading gate off it. `"error"`
|
|
30
|
+
/** Lifecycle of the last save/load — drive a "Saving…"/"Saved" indicator or a loading gate off it. `"error"` includes rejected reads, writes, removals and slot metadata updates; metadata failure can follow a successful payload write. */
|
|
30
31
|
export type SaveStatus = "idle" | "loading" | "saving" | "saved" | "error";
|
|
31
32
|
/** Autosave cadence: debounce writes `debounceMs` after the last edit, but never wait longer than `maxWaitMs` while edits keep coming (`0` removes the ceiling). `autosave: true` uses `{ debounceMs: 1000, maxWaitMs: 15000 }`. */
|
|
32
33
|
export interface SaveAutoConfig {
|