@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.
Files changed (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -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
- for (const entry of rewards.items ?? []) {
79
- const fail = deps.rewards.grantItem(userId, entry.inventory, entry.item, entry.count);
80
- if (fail !== null)
81
- return fail;
82
- }
83
- if (rewards.xp)
84
- deps.rewards.grantXp(userId, rewards.xp.amount);
85
- for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
86
- deps.rewards.grantEconomy(userId, currencyId, amount);
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
- if (def.rewards) {
100
- const fail = applyRewards(userId, def.rewards);
101
- if (fail !== null)
102
- return fail;
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
- state.status = "completed";
105
- deps.events.emit("quest.completed", { userId, questId });
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
- for (const def of catalog.values()) {
114
- for (const objective of def.objectives) {
115
- if (objective.kind !== "kill" || objective.target !== catalogId)
116
- continue;
117
- const recipients = new Set([killerUserId]);
118
- if (objective.partyShare?.credit === "all" && deps.partyMembersNear) {
119
- for (const member of deps.partyMembersNear(killerUserId, objective.partyShare.radius)) {
120
- recipients.add(member);
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
- for (const def of catalog.values()) {
130
- for (const objective of def.objectives) {
131
- if (objective.kind !== "collect" || objective.item !== itemId)
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(userId, data) {
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
- const quests = new Map();
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
- for (const entry of rewards.items ?? []) {
295
- const fail = appliers.grantItem?.(entry.inventory, entry.item, entry.count);
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
+ }
@@ -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 memory-only. Storage errors (quota exceeded, private mode, no
20
- * DOM) degrade to no-ops, so a save never throws into a game tick.
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"` means the backend rejected a read or write. */
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 {