@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
@@ -1,3 +1,4 @@
1
+ import { resolveOneShotClip } from "../game/modelAnimation.js";
1
2
  const DEFAULT_FADE = 0.2;
2
3
  /** Entity blackboard key the shell reads extra graph parameters from: `ctx.scene.entity.blackboard.set(id, ANIM_PARAMS_KEY, { aiming: true })`. */
3
4
  export const ANIM_PARAMS_KEY = "anim.params";
@@ -126,7 +127,7 @@ function clipTimeFor(time, duration, loop) {
126
127
  *
127
128
  * @capability anim-graph data-first animation state machine with blend trees, crossfades, layers, and clip events
128
129
  */
129
- export function createAnimGraphRuntime(initial) {
130
+ export function createAnimGraphRuntime(initial, options = {}) {
130
131
  let graph = initial;
131
132
  let triggers = new Set();
132
133
  let layers = {};
@@ -136,6 +137,14 @@ export function createAnimGraphRuntime(initial) {
136
137
  layers[layer.id] = { current: layer.entry, time: 0, transition: null };
137
138
  }
138
139
  reset();
140
+ function chooseClip(state, def) {
141
+ if (def.kind === "clip" && def.variants !== undefined && def.variants.length > 0) {
142
+ state.selectedClip = resolveOneShotClip({ choice: def.variants }, "choice", options.rng?.() ?? 0) ?? def.clip;
143
+ }
144
+ else {
145
+ delete state.selectedClip;
146
+ }
147
+ }
139
148
  function pickTransition(layer, state, params, normalized) {
140
149
  for (const transition of layer.transitions) {
141
150
  if (transition.from !== "*" && transition.from !== state.current)
@@ -162,8 +171,7 @@ export function createAnimGraphRuntime(initial) {
162
171
  continue;
163
172
  const at = event.atSec;
164
173
  if (loop) {
165
- const wrapped = after < before;
166
- const hit = wrapped ? at > before || at <= after : at > before && at <= after;
174
+ const hit = at >= 0 && at <= duration && Math.floor((after - at) / duration) > Math.floor((before - at) / duration);
167
175
  if (hit)
168
176
  out.push({ name: event.name, clip });
169
177
  }
@@ -205,18 +213,24 @@ export function createAnimGraphRuntime(initial) {
205
213
  const def = layer.states[state.current];
206
214
  if (def === undefined)
207
215
  continue;
216
+ if (def.kind === "clip" && def.variants !== undefined && state.selectedClip === undefined)
217
+ chooseClip(state, def);
208
218
  const layerWeight = layer.weight ?? 1;
209
219
  if (def.rootMotion === true && layerWeight > 0)
210
220
  rootMotion = true;
211
221
  const before = state.time;
212
222
  state.time += dt * stateSpeed(def);
213
- const weights = stateClipWeights(def, params);
223
+ const weights = def.kind === "clip" && state.selectedClip !== undefined && def.variants?.includes(state.selectedClip)
224
+ ? { [state.selectedClip]: 1 }
225
+ : stateClipWeights(def, params);
214
226
  const duration = stateDuration(weights, clips);
215
227
  const loop = stateLoops(def);
216
228
  const normalized = duration > 0 ? (loop ? (state.time % duration) / duration : Math.min(1, state.time / duration)) : 1;
217
229
  for (const clip of Object.keys(weights)) {
230
+ if (!(weights[clip] * layerWeight > 0))
231
+ continue;
218
232
  const duration = clipDuration(clips[clip]);
219
- collectEvents(output.events, clip, clipTimeFor(before, duration, loop), clipTimeFor(state.time, duration, loop), duration, loop);
233
+ collectEvents(output.events, clip, before, state.time, duration, loop);
220
234
  if (def.rootMotion) {
221
235
  const info = clips[clip];
222
236
  if (typeof info !== "number" && info?.rootTrack !== undefined) {
@@ -235,7 +249,13 @@ export function createAnimGraphRuntime(initial) {
235
249
  const times = {};
236
250
  for (const [clip, w] of Object.entries(transition.fromWeights)) {
237
251
  merged[clip] = (merged[clip] ?? 0) + w * (1 - t);
238
- times[clip] = transition.fromTimes[clip] ?? 0;
252
+ const playback = transition.fromPlayback?.[clip];
253
+ if (w * (1 - t) * layerWeight > 0 && playback?.rootMotion === true)
254
+ rootMotion = true;
255
+ const time = transition.fromTimes[clip] ?? 0;
256
+ const nextTime = playback === undefined ? time : clipTimeFor(time + dt * playback.speed, clipDuration(clips[clip]), playback.loop);
257
+ transition.fromTimes[clip] = nextTime;
258
+ times[clip] = nextTime;
239
259
  }
240
260
  for (const [clip, w] of Object.entries(weights)) {
241
261
  merged[clip] = (merged[clip] ?? 0) + w * t;
@@ -257,28 +277,39 @@ export function createAnimGraphRuntime(initial) {
257
277
  }
258
278
  }
259
279
  const next = pickTransition(layer, state, params, normalized);
260
- if (next !== null && next.to !== state.current) {
280
+ if (next !== null) {
261
281
  const fromWeights = {};
262
282
  const fromTimes = {};
283
+ const fromPlayback = {};
263
284
  for (const entry of output.clips) {
264
285
  if (entry.layer !== layer.id)
265
286
  continue;
266
287
  fromWeights[entry.clip] = (fromWeights[entry.clip] ?? 0) + entry.weight / (layerWeight || 1);
267
288
  fromTimes[entry.clip] = entry.time;
289
+ const previous = transition?.fromPlayback?.[entry.clip];
290
+ const outgoingRoot = previous?.rootMotion === true && transition !== null &&
291
+ transition.duration > 0 && transition.elapsed < transition.duration &&
292
+ (transition.fromWeights[entry.clip] ?? 0) > 0;
293
+ const playback = weights[entry.clip] === undefined && previous !== undefined
294
+ ? previous : { speed: stateSpeed(def), loop };
295
+ // Same-clip states share one clock, but an interrupted fade must retain either
296
+ // influencing source's in-place policy until that source leaves the pose.
297
+ fromPlayback[entry.clip] = { ...playback, ...(def.rootMotion === true || outgoingRoot ? { rootMotion: true } : {}) };
268
298
  }
269
299
  state.current = next.to;
270
300
  state.time = 0;
301
+ const target = layer.states[next.to];
302
+ if (target !== undefined)
303
+ chooseClip(state, target);
271
304
  state.transition = {
272
305
  to: next.to,
273
306
  elapsed: 0,
274
307
  duration: next.duration ?? DEFAULT_FADE,
275
308
  fromWeights,
276
309
  fromTimes,
310
+ fromPlayback,
277
311
  };
278
312
  }
279
- else if (next !== null && next.to === state.current) {
280
- state.time = 0;
281
- }
282
313
  }
283
314
  triggers.clear();
284
315
  if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0)
@@ -305,8 +336,10 @@ function parseState(value) {
305
336
  ...(typeof raw.loop === "boolean" ? { loop: raw.loop } : {}),
306
337
  ...(typeof raw.rootMotion === "boolean" ? { rootMotion: raw.rootMotion } : {}),
307
338
  };
308
- if (raw.kind === "clip" && typeof raw.clip === "string")
309
- return { kind: "clip", clip: raw.clip, ...extra };
339
+ if (raw.kind === "clip" && typeof raw.clip === "string") {
340
+ const variants = Array.isArray(raw.variants) ? raw.variants.filter((clip) => typeof clip === "string" && clip.length > 0) : undefined;
341
+ return { kind: "clip", clip: raw.clip, ...(variants === undefined || variants.length === 0 ? {} : { variants }), ...extra };
342
+ }
310
343
  if (!Array.isArray(raw.points))
311
344
  return null;
312
345
  if (raw.kind === "blend1D" && typeof raw.param === "string") {
@@ -412,3 +445,158 @@ export function parseAnimGraph(value) {
412
445
  : [];
413
446
  return { layers, ...(events.length === 0 ? {} : { events }) };
414
447
  }
448
+ /**
449
+ * Validate an authored graph without dropping broken combat states or weakening transition conditions.
450
+ * Known malformed fields and dangling references reject the whole graph with relative repair locations;
451
+ * valid data, including authored object/array order, is returned unchanged. Unknown extension fields are
452
+ * retained. Clip availability belongs to the imported-rig diagnostics. Use {@link parseAnimGraph} when
453
+ * permissive repair is intentional.
454
+ * @capability anim-graph validate authored animation graphs with located repair diagnostics without partial fallback
455
+ */
456
+ export function validateAnimGraph(value) {
457
+ const diagnostics = [];
458
+ const issue = (path, message, repair) => { diagnostics.push({ path, message, repair }); };
459
+ const field = (path, key) => path === "" ? key : /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
460
+ const object = (candidate, path) => {
461
+ const raw = record(candidate);
462
+ if (raw === null)
463
+ issue(path, "Expected an animation graph object.", "Supply an object with the supported fields.");
464
+ return raw;
465
+ };
466
+ const text = (candidate, path, empty = false) => {
467
+ if (typeof candidate === "string" && (empty || candidate.trim().length > 0))
468
+ return true;
469
+ issue(path, "Expected a nonempty string.", "Set an explicit name from this graph or imported rig.");
470
+ return false;
471
+ };
472
+ const number = (candidate, path, min = -Infinity, max = Infinity) => {
473
+ if (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= min && candidate <= max)
474
+ return true;
475
+ issue(path, `Expected a finite number${min === -Infinity && max === Infinity ? "" : max === Infinity ? ` at least ${min}` : ` between ${min} and ${max}`}.`, "Set a finite value in the supported range.");
476
+ return false;
477
+ };
478
+ const boolean = (candidate, path) => {
479
+ if (typeof candidate !== "boolean")
480
+ issue(path, "Expected a boolean.", "Set true or false, or omit the optional field.");
481
+ };
482
+ const array = (candidate, path) => {
483
+ if (Array.isArray(candidate))
484
+ return candidate;
485
+ issue(path, "Expected an array.", "Supply an array of supported entries.");
486
+ return null;
487
+ };
488
+ const tuple = (candidate, path, numeric) => {
489
+ const entries = array(candidate, path);
490
+ if (entries === null)
491
+ return;
492
+ if (entries.length !== 2)
493
+ issue(path, "Expected exactly two entries.", "Supply the two blend axes in authored order.");
494
+ entries.forEach((entry, index) => numeric ? number(entry, `${path}[${index}]`) : text(entry, `${path}[${index}]`));
495
+ };
496
+ const state = (candidate, path) => {
497
+ const raw = object(candidate, path);
498
+ if (raw === null)
499
+ return;
500
+ if (!["clip", "blend1D", "blend2D"].includes(raw.kind)) {
501
+ issue(`${path}.kind`, "Unknown animation state kind.", "Choose clip, blend1D or blend2D.");
502
+ return;
503
+ }
504
+ if (raw.speed !== undefined)
505
+ number(raw.speed, `${path}.speed`);
506
+ for (const key of ["loop", "rootMotion"])
507
+ if (raw[key] !== undefined)
508
+ boolean(raw[key], `${path}.${key}`);
509
+ const allowed = raw.kind === "clip" ? ["clip", "variants"] : raw.kind === "blend1D" ? ["param", "points"] : ["params", "points"];
510
+ for (const key of ["clip", "variants", "param", "params", "points"])
511
+ if (raw[key] !== undefined && !allowed.includes(key)) {
512
+ issue(`${path}.${key}`, `Field ${key} is not used by a ${raw.kind} state.`, "Remove the field or choose the matching state kind.");
513
+ }
514
+ if (raw.kind === "clip") {
515
+ text(raw.clip, `${path}.clip`);
516
+ if (raw.variants !== undefined)
517
+ array(raw.variants, `${path}.variants`)?.forEach((clip, index) => text(clip, `${path}.variants[${index}]`));
518
+ return;
519
+ }
520
+ if (raw.kind === "blend1D")
521
+ text(raw.param, `${path}.param`);
522
+ else
523
+ tuple(raw.params, `${path}.params`, false);
524
+ array(raw.points, `${path}.points`)?.forEach((candidate, index) => {
525
+ const pointPath = `${path}.points[${index}]`, point = object(candidate, pointPath);
526
+ if (point === null)
527
+ return;
528
+ text(point.clip, `${pointPath}.clip`);
529
+ if (raw.kind === "blend1D")
530
+ number(point.at, `${pointPath}.at`);
531
+ else
532
+ tuple(point.at, `${pointPath}.at`, true);
533
+ });
534
+ };
535
+ const raw = object(value, "");
536
+ if (raw === null)
537
+ return { graph: undefined, diagnostics };
538
+ const layers = array(raw.layers, "layers"), seen = new Set();
539
+ if (layers?.length === 0)
540
+ issue("layers", "An animation graph needs at least one layer.", "Add a layer with an entry state.");
541
+ layers?.forEach((candidate, index) => {
542
+ const path = `layers[${index}]`, layer = object(candidate, path);
543
+ if (layer === null)
544
+ return;
545
+ if (text(layer.id, `${path}.id`)) {
546
+ if (seen.has(layer.id))
547
+ issue(`${path}.id`, "Layer id is duplicated.", "Give each layer a distinct id.");
548
+ seen.add(layer.id);
549
+ }
550
+ const validEntry = text(layer.entry, `${path}.entry`), states = object(layer.states, `${path}.states`);
551
+ if (states !== null) {
552
+ for (const [name, candidate] of Object.entries(states)) {
553
+ text(name, field(`${path}.states`, name));
554
+ state(candidate, field(`${path}.states`, name));
555
+ }
556
+ if (validEntry && !Object.hasOwn(states, layer.entry))
557
+ issue(`${path}.entry`, "Entry state does not exist in this layer.", "Choose a state declared in this layer.");
558
+ }
559
+ if (layer.weight !== undefined)
560
+ number(layer.weight, `${path}.weight`, 0, 1);
561
+ if (layer.additive !== undefined)
562
+ boolean(layer.additive, `${path}.additive`);
563
+ if (layer.mask !== undefined)
564
+ array(layer.mask, `${path}.mask`)?.forEach((prefix, i) => text(prefix, `${path}.mask[${i}]`, true));
565
+ array(layer.transitions, `${path}.transitions`)?.forEach((candidate, i) => {
566
+ const edgePath = `${path}.transitions[${i}]`, edge = object(candidate, edgePath);
567
+ if (edge === null)
568
+ return;
569
+ for (const key of ["from", "to"])
570
+ if (text(edge[key], `${edgePath}.${key}`) && states !== null && !(key === "from" && edge[key] === "*") && !Object.hasOwn(states, edge[key])) {
571
+ issue(`${edgePath}.${key}`, "Transition references an unknown state.", "Choose a state declared in this layer; only from may use *.");
572
+ }
573
+ if (edge.trigger !== undefined)
574
+ text(edge.trigger, `${edgePath}.trigger`);
575
+ if (edge.duration !== undefined)
576
+ number(edge.duration, `${edgePath}.duration`, 0);
577
+ if (edge.exitTime !== undefined)
578
+ number(edge.exitTime, `${edgePath}.exitTime`);
579
+ if (edge.when !== undefined)
580
+ array(edge.when, `${edgePath}.when`)?.forEach((candidate, i) => {
581
+ const conditionPath = `${edgePath}.when[${i}]`, condition = object(candidate, conditionPath);
582
+ if (condition === null)
583
+ return;
584
+ text(condition.param, `${conditionPath}.param`);
585
+ if (!COMPARES.includes(condition.op))
586
+ issue(`${conditionPath}.op`, "Unknown comparison operator.", "Choose >, <, >=, <=, == or !=.");
587
+ if (typeof condition.value !== "boolean")
588
+ number(condition.value, `${conditionPath}.value`);
589
+ });
590
+ });
591
+ });
592
+ if (raw.events !== undefined)
593
+ array(raw.events, "events")?.forEach((candidate, index) => {
594
+ const path = `events[${index}]`, event = object(candidate, path);
595
+ if (event === null)
596
+ return;
597
+ text(event.clip, `${path}.clip`);
598
+ text(event.name, `${path}.name`);
599
+ number(event.atSec, `${path}.atSec`, 0);
600
+ });
601
+ return { graph: diagnostics.length === 0 ? value : undefined, diagnostics };
602
+ }
@@ -12,10 +12,16 @@ export interface LocomotionGraphInput {
12
12
  /** Crossfade seconds between locomotion states. Default 0.2. */
13
13
  fadeSec?: number;
14
14
  /** One-shot clips keyed by trigger name; `death` holds its last frame instead of returning. */
15
- oneShots?: Readonly<Record<string, string>>;
15
+ oneShots?: Readonly<Record<string, string | readonly string[]>>;
16
16
  }
17
17
  /** The parameter name the shell feeds with the entity's smoothed ground speed. */
18
18
  export declare const LOCOMOTION_SPEED_PARAM = "speed";
19
+ /** Ground contact reported by the shared player motor; absent for custom movers without authored parameters. */
20
+ export declare const LOCOMOTION_GROUNDED_PARAM = "grounded";
21
+ /** Resolved vertical velocity from the shared player motor, in world units/sec. */
22
+ export declare const LOCOMOTION_VERTICAL_SPEED_PARAM = "verticalSpeed";
23
+ /** Physically resolved crouch stance from the shared player motor. */
24
+ export declare const LOCOMOTION_CROUCHED_PARAM = "crouched";
19
25
  /** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
20
26
  export declare const LOCOMOTION_LAYER = "base";
21
27
  /**
@@ -27,8 +33,10 @@ export declare const LOCOMOTION_LAYER = "base";
27
33
  export declare function locomotionGraph(input: LocomotionGraphInput): AnimGraph;
28
34
  /**
29
35
  * The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
30
- * `states` and `oneShots` describe (a `string[]` one-shot uses its first variant). `undefined` for a
31
- * single-clip config. The shell plays this and the editor inspects it, so both see the same graph.
36
+ * `states` and `oneShots` describe, retaining every one-shot variant. `undefined` for a
37
+ * single-clip config or incomplete locomotion roles. Idle must name a clip; an absent or blank
38
+ * walk role holds that idle clip at walking speeds until configured. The shell plays this and
39
+ * the editor inspects it, so both see the same graph.
32
40
  *
33
41
  * @capability locomotion-graph resolve the animation graph a model config plays
34
42
  */
@@ -1,5 +1,11 @@
1
1
  /** The parameter name the shell feeds with the entity's smoothed ground speed. */
2
2
  export const LOCOMOTION_SPEED_PARAM = "speed";
3
+ /** Ground contact reported by the shared player motor; absent for custom movers without authored parameters. */
4
+ export const LOCOMOTION_GROUNDED_PARAM = "grounded";
5
+ /** Resolved vertical velocity from the shared player motor, in world units/sec. */
6
+ export const LOCOMOTION_VERTICAL_SPEED_PARAM = "verticalSpeed";
7
+ /** Physically resolved crouch stance from the shared player motor. */
8
+ export const LOCOMOTION_CROUCHED_PARAM = "crouched";
3
9
  /** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
4
10
  export const LOCOMOTION_LAYER = "base";
5
11
  /**
@@ -20,10 +26,18 @@ export function locomotionGraph(input) {
20
26
  const states = {
21
27
  locomotion: { kind: "blend1D", param: LOCOMOTION_SPEED_PARAM, points },
22
28
  };
29
+ const oneShots = Object.entries(input.oneShots ?? {}).filter(([, spec]) => typeof spec === "string" || spec.length > 0);
30
+ for (const [name, spec] of oneShots) {
31
+ const clip = typeof spec === "string" ? spec : spec[0];
32
+ states[name] = { kind: "clip", clip, loop: false, ...(typeof spec === "string" || spec.length < 2 ? {} : { variants: [...spec] }) };
33
+ }
23
34
  const transitions = [];
24
- for (const [name, clip] of Object.entries(input.oneShots ?? {})) {
25
- states[name] = { kind: "clip", clip, loop: false };
26
- transitions.push({ from: "*", to: name, trigger: name, duration: 0.1 });
35
+ oneShots.sort(([a], [b]) => Number(b === "death") - Number(a === "death"));
36
+ for (const [name] of oneShots) {
37
+ for (const from of Object.keys(states)) {
38
+ if (from !== "death")
39
+ transitions.push({ from, to: name, trigger: name, duration: 0.1 });
40
+ }
27
41
  if (name !== "death")
28
42
  transitions.push({ from: name, to: "locomotion", exitTime: 1, duration: fade });
29
43
  }
@@ -33,8 +47,10 @@ export function locomotionGraph(input) {
33
47
  }
34
48
  /**
35
49
  * The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
36
- * `states` and `oneShots` describe (a `string[]` one-shot uses its first variant). `undefined` for a
37
- * single-clip config. The shell plays this and the editor inspects it, so both see the same graph.
50
+ * `states` and `oneShots` describe, retaining every one-shot variant. `undefined` for a
51
+ * single-clip config or incomplete locomotion roles. Idle must name a clip; an absent or blank
52
+ * walk role holds that idle clip at walking speeds until configured. The shell plays this and
53
+ * the editor inspects it, so both see the same graph.
38
54
  *
39
55
  * @capability locomotion-graph resolve the animation graph a model config plays
40
56
  */
@@ -42,21 +58,15 @@ export function animGraphFromConfig(config) {
42
58
  if (config.graph !== undefined)
43
59
  return config.graph;
44
60
  const states = config.states;
45
- if (states === undefined)
61
+ if (states === undefined || typeof states.idle !== "string" || states.idle.trim().length === 0)
46
62
  return undefined;
47
- const oneShots = {};
48
- for (const [event, spec] of Object.entries(config.oneShots ?? {})) {
49
- const clip = typeof spec === "string" ? spec : spec[0];
50
- if (clip !== undefined)
51
- oneShots[event] = clip;
52
- }
53
63
  return locomotionGraph({
54
64
  idle: states.idle,
55
- walk: states.walk,
56
- ...(states.run === undefined ? {} : { run: states.run }),
65
+ walk: typeof states.walk === "string" && states.walk.trim().length > 0 ? states.walk : states.idle,
66
+ ...(typeof states.run !== "string" || states.run.trim().length === 0 ? {} : { run: states.run }),
57
67
  walkSpeed: states.walkSpeed,
58
68
  runSpeed: states.runSpeed,
59
69
  fadeSec: states.fadeSec,
60
- ...(Object.keys(oneShots).length === 0 ? {} : { oneShots }),
70
+ ...(config.oneShots === undefined ? {} : { oneShots: config.oneShots }),
61
71
  });
62
72
  }
@@ -24,6 +24,10 @@ export interface SoundDef {
24
24
  /** Base gain before bus/falloff multipliers. Default 1. */
25
25
  gain?: number;
26
26
  loop?: boolean;
27
+ /** Maximum simultaneous reservations for this sound, including pending loads. Default the engine's total voice limit. */
28
+ maxVoices?: number;
29
+ /** Playback priority; higher priorities survive voice-budget pressure. Default 0. */
30
+ priority?: number;
27
31
  /** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
28
32
  positional?: boolean;
29
33
  falloff?: AudioFalloffConfig;
@@ -0,0 +1,46 @@
1
+ /** One reserved playback slot, including sources still loading. */
2
+ export interface AudioVoice {
3
+ id: number;
4
+ soundId: string;
5
+ priority: number;
6
+ }
7
+ /** Detached allocator state; ids increase monotonically for deterministic oldest-first ties. */
8
+ export interface VoiceAllocatorSnapshot {
9
+ nextId: number;
10
+ voices: AudioVoice[];
11
+ }
12
+ /** Caller-owned reservation storage. Reads and writes use detached state. */
13
+ export interface VoiceAllocatorStorage {
14
+ read(): VoiceAllocatorSnapshot;
15
+ write(state: VoiceAllocatorSnapshot): void;
16
+ }
17
+ /** Playback budget. Zero disables playback; default 64. */
18
+ export interface VoiceAllocatorConfig {
19
+ maxTotal?: number;
20
+ /** At capacity, steal the lowest-priority oldest voice, or reject new voices. Default "steal-lowest". */
21
+ overflow?: "steal-lowest" | "reject";
22
+ storage?: VoiceAllocatorStorage;
23
+ }
24
+ /** Admission result; stop every stolen source before starting the admitted voice. */
25
+ export type VoiceAllocation = {
26
+ ok: false;
27
+ } | {
28
+ ok: true;
29
+ voiceId: number;
30
+ stolen: number[];
31
+ };
32
+ /** Bounded reservations with higher-priority protection and oldest-first ties. */
33
+ export interface VoiceAllocator {
34
+ request(soundId: string, priority?: number, maxVoices?: number): VoiceAllocation;
35
+ release(voiceId: number): void;
36
+ snapshot(): VoiceAllocatorSnapshot;
37
+ restore(state: VoiceAllocatorSnapshot): void;
38
+ /** Return reservations removed to meet the new limit; the playback owner stops them. */
39
+ retune(config: Pick<VoiceAllocatorConfig, "maxTotal" | "overflow">): number[];
40
+ }
41
+ /**
42
+ * Reserve bounded sound playback before fetching or constructing a graph. Equal priorities steal
43
+ * the oldest reservation; lower priorities cannot interrupt higher priorities. Policy is retunable.
44
+ * @capability audio-voice-budget bound simultaneous sound playback with per-sound caps, priorities, and deterministic stealing
45
+ */
46
+ export declare function createVoiceAllocator(config?: VoiceAllocatorConfig): VoiceAllocator;
@@ -0,0 +1,76 @@
1
+ function limit(value) {
2
+ if (!Number.isFinite(value) || value < 0)
3
+ throw new RangeError("Voice limit must be finite and nonnegative");
4
+ return Math.floor(value);
5
+ }
6
+ function copy(state) {
7
+ return { nextId: state.nextId, voices: state.voices.map((voice) => ({ ...voice })) };
8
+ }
9
+ function weakest(voices) {
10
+ return [...voices].sort((a, b) => a.priority - b.priority || a.id - b.id);
11
+ }
12
+ /**
13
+ * Reserve bounded sound playback before fetching or constructing a graph. Equal priorities steal
14
+ * the oldest reservation; lower priorities cannot interrupt higher priorities. Policy is retunable.
15
+ * @capability audio-voice-budget bound simultaneous sound playback with per-sound caps, priorities, and deterministic stealing
16
+ */
17
+ export function createVoiceAllocator(config = {}) {
18
+ let maxTotal = limit(config.maxTotal ?? 64);
19
+ let overflow = config.overflow ?? "steal-lowest";
20
+ let memory = { nextId: 1, voices: [] };
21
+ const storage = config.storage ?? { read: () => memory, write: (next) => { memory = next; } };
22
+ function snapshot() { return copy(storage.read()); }
23
+ function restore(state) {
24
+ const ids = new Set();
25
+ if (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.voices.length > maxTotal)
26
+ throw new RangeError("Invalid voice state");
27
+ for (const voice of state.voices) {
28
+ if (!Number.isSafeInteger(voice.id) || voice.id < 1 || voice.id >= state.nextId || ids.has(voice.id) || !Number.isFinite(voice.priority))
29
+ throw new RangeError("Invalid voice state");
30
+ ids.add(voice.id);
31
+ }
32
+ storage.write(copy(state));
33
+ }
34
+ restore(snapshot());
35
+ return {
36
+ snapshot, restore,
37
+ request(soundId, priority = 0, maxVoices = maxTotal) {
38
+ if (!Number.isFinite(priority))
39
+ throw new RangeError("Voice priority must be finite");
40
+ const soundLimit = limit(maxVoices);
41
+ if (maxTotal === 0 || soundLimit === 0)
42
+ return { ok: false };
43
+ const state = snapshot();
44
+ const same = weakest(state.voices.filter((voice) => voice.soundId === soundId));
45
+ const victims = same.slice(0, Math.max(0, same.length - soundLimit + 1));
46
+ const selected = new Set(victims.map((voice) => voice.id));
47
+ const remaining = weakest(state.voices.filter((voice) => !selected.has(voice.id)));
48
+ victims.push(...remaining.slice(0, Math.max(0, state.voices.length - victims.length - maxTotal + 1)));
49
+ if ((overflow === "reject" && victims.length > 0) || victims.some((voice) => voice.priority > priority))
50
+ return { ok: false };
51
+ const stolen = victims.map((voice) => voice.id);
52
+ const removed = new Set(stolen);
53
+ const voiceId = state.nextId++;
54
+ state.voices = state.voices.filter((voice) => !removed.has(voice.id));
55
+ state.voices.push({ id: voiceId, soundId, priority });
56
+ restore(state);
57
+ return { ok: true, voiceId, stolen };
58
+ },
59
+ release(voiceId) {
60
+ const state = snapshot();
61
+ state.voices = state.voices.filter((voice) => voice.id !== voiceId);
62
+ restore(state);
63
+ },
64
+ retune(next) {
65
+ const nextLimit = limit(next.maxTotal ?? maxTotal);
66
+ const state = snapshot();
67
+ const removed = weakest(state.voices).slice(0, Math.max(0, state.voices.length - nextLimit)).map((voice) => voice.id);
68
+ const ids = new Set(removed);
69
+ state.voices = state.voices.filter((voice) => !ids.has(voice.id));
70
+ maxTotal = nextLimit;
71
+ overflow = next.overflow ?? overflow;
72
+ restore(state);
73
+ return removed;
74
+ },
75
+ };
76
+ }
@@ -51,6 +51,21 @@ export interface AbilitySlotRetune {
51
51
  cooldownMs?: number;
52
52
  resourceCost?: number;
53
53
  }
54
+ /** Mutable kit state; slot/group definitions and any bound resource are saved by the caller. */
55
+ export interface AbilityKitState {
56
+ slots: {
57
+ id: string;
58
+ cooldownMs: number;
59
+ resourceCost: number;
60
+ charges: number;
61
+ rechargeRemainingMs: number;
62
+ flashRemainingMs: number;
63
+ }[];
64
+ groups: {
65
+ id: string;
66
+ remainingMs: number;
67
+ }[];
68
+ }
54
69
  export type AbilityCastResult = {
55
70
  ok: true;
56
71
  slot: AbilitySlotSnapshot;
@@ -62,8 +77,12 @@ export type AbilityCastResult = {
62
77
  export interface AbilityKit {
63
78
  slots(): readonly string[];
64
79
  config(slotId: string): AbilitySlotConfig | null;
80
+ /** Detached persistence state, including in-flight timers and slot retuning. */
81
+ state(): AbilityKitState;
65
82
  state(slotId: string, resourceAvailable?: number): AbilitySlotSnapshot | null;
66
83
  snapshot(resourceAvailable?: number): AbilitySlotSnapshot[];
84
+ /** Restore without spending resources; returns false without mutation for mismatched slot/group ids. */
85
+ restore(next: AbilityKitState): boolean;
67
86
  canCast(slotId: string, resourceAvailable?: number): AbilityCastResult;
68
87
  cast(slotId: string, resourceAvailable?: number): AbilityCastResult;
69
88
  tick(dtSeconds: number): void;
@@ -108,6 +108,26 @@ export function createAbilityKit(configs, options = {}) {
108
108
  return fail(slotId, "no-resource", resourceAvailable);
109
109
  return { ok: true, slot: snapshotOf(runtime, resourceAvailable, groupBlockOf(runtime)) };
110
110
  }
111
+ function state(slotId, resourceAvailable) {
112
+ if (slotId !== undefined) {
113
+ const runtime = runtimes.get(slotId);
114
+ return runtime === undefined ? null : snapshotOf(runtime, currentResource(resourceAvailable), groupBlockOf(runtime));
115
+ }
116
+ return {
117
+ slots: order.map((id) => {
118
+ const runtime = runtimes.get(id);
119
+ return {
120
+ id,
121
+ cooldownMs: runtime.config.cooldownMs,
122
+ resourceCost: runtime.config.resourceCost,
123
+ charges: runtime.charges,
124
+ rechargeRemainingMs: runtime.rechargeRemainingMs,
125
+ flashRemainingMs: runtime.flashRemainingMs,
126
+ };
127
+ }),
128
+ groups: Array.from(groupRemaining, ([id, remainingMs]) => ({ id, remainingMs })),
129
+ };
130
+ }
111
131
  return {
112
132
  slots() {
113
133
  return order.slice();
@@ -115,10 +135,7 @@ export function createAbilityKit(configs, options = {}) {
115
135
  config(slotId) {
116
136
  return runtimes.get(slotId)?.config ?? null;
117
137
  },
118
- state(slotId, resourceAvailable) {
119
- const runtime = runtimes.get(slotId);
120
- return runtime === undefined ? null : snapshotOf(runtime, currentResource(resourceAvailable), groupBlockOf(runtime));
121
- },
138
+ state,
122
139
  snapshot(resourceAvailable) {
123
140
  const available = currentResource(resourceAvailable);
124
141
  return order.map((slotId) => {
@@ -126,6 +143,25 @@ export function createAbilityKit(configs, options = {}) {
126
143
  return snapshotOf(runtime, available, groupBlockOf(runtime));
127
144
  });
128
145
  },
146
+ restore(next) {
147
+ if (next.slots.length !== runtimes.size || next.groups.length !== groupRemaining.size ||
148
+ new Set(next.slots.map((slot) => slot.id)).size !== runtimes.size ||
149
+ new Set(next.groups.map((group) => group.id)).size !== groupRemaining.size ||
150
+ next.slots.some((slot) => !runtimes.has(slot.id)) ||
151
+ next.groups.some((group) => !groupRemaining.has(group.id)))
152
+ return false;
153
+ for (const slot of next.slots) {
154
+ const runtime = runtimes.get(slot.id);
155
+ runtime.config.cooldownMs = Math.max(0, slot.cooldownMs);
156
+ runtime.config.resourceCost = Math.max(0, slot.resourceCost);
157
+ runtime.charges = Math.max(0, Math.min(runtime.config.chargesMax, slot.charges));
158
+ runtime.rechargeRemainingMs = Math.max(0, slot.rechargeRemainingMs);
159
+ runtime.flashRemainingMs = Math.max(0, slot.flashRemainingMs);
160
+ }
161
+ for (const group of next.groups)
162
+ groupRemaining.set(group.id, Math.max(0, group.remainingMs));
163
+ return true;
164
+ },
129
165
  canCast(slotId, resourceAvailable) {
130
166
  return evaluate(slotId, currentResource(resourceAvailable));
131
167
  },
@@ -6,8 +6,9 @@ import type { EffectVia } from "./effects.js";
6
6
  export type DeathReasonKind = DeathReason["kind"];
7
7
  export interface OnDeathDropRule {
8
8
  table: string;
9
+ /** `"any"` explicitly permits world item drops without a player killer. */
9
10
  when?: {
10
- reason: DeathReasonKind;
11
+ reason: DeathReasonKind | "any";
11
12
  };
12
13
  }
13
14
  export interface OnDeathCommandRule {
@@ -47,13 +48,13 @@ export interface DeathIdentity {
47
48
  position: [number, number, number];
48
49
  }
49
50
  export interface DeathSystemDeps {
50
- resolveOnDeath(instanceId: string): OnDeathSpec | null | undefined;
51
+ resolveOnDeath(instanceId: string, reason?: DeathReason): OnDeathSpec | null | undefined;
51
52
  resolveIdentity(instanceId: string): DeathIdentity | null;
52
53
  loot: {
53
54
  roll(tableId: string): Drop[];
54
55
  };
55
56
  events: GameEvents;
56
- runCommand?(name: string, args: unknown): void;
57
+ runCommand?(name: string, args: unknown, reason?: DeathReason): void;
57
58
  despawn(instanceId: string): void;
58
59
  }
59
60
  export type DeathResolution = {