@jgengine/core 0.8.0 → 0.10.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 (303) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/ai/flock.d.ts +36 -0
  3. package/dist/ai/flock.js +182 -0
  4. package/dist/ai/groupAssist.d.ts +19 -0
  5. package/dist/ai/groupAssist.js +59 -0
  6. package/dist/ai/laneSelect.d.ts +21 -0
  7. package/dist/ai/laneSelect.js +37 -0
  8. package/dist/ai/mobBrain.d.ts +68 -0
  9. package/dist/ai/mobBrain.js +115 -0
  10. package/dist/ai/spawnDirector.d.ts +13 -0
  11. package/dist/ai/spawnDirector.js +41 -17
  12. package/dist/ai/spawnPoint.d.ts +5 -0
  13. package/dist/ai/spawnPoint.js +5 -0
  14. package/dist/ai/threat.d.ts +2 -0
  15. package/dist/ai/threat.js +34 -0
  16. package/dist/audio/audioFalloff.d.ts +5 -2
  17. package/dist/audio/music.d.ts +53 -0
  18. package/dist/audio/music.js +34 -0
  19. package/dist/audio/synth.d.ts +60 -0
  20. package/dist/audio/synth.js +8 -0
  21. package/dist/authoring.d.ts +14 -0
  22. package/dist/authoring.js +14 -0
  23. package/dist/cartridge/runtime.d.ts +15 -0
  24. package/dist/cartridge/runtime.js +556 -0
  25. package/dist/cartridge/spec.d.ts +188 -0
  26. package/dist/cartridge/spec.js +19 -0
  27. package/dist/cartridge/validate.d.ts +2 -0
  28. package/dist/cartridge/validate.js +78 -0
  29. package/dist/combat/abilityKit.d.ts +17 -2
  30. package/dist/combat/abilityKit.js +62 -13
  31. package/dist/combat/castRunner.d.ts +40 -0
  32. package/dist/combat/castRunner.js +56 -0
  33. package/dist/combat/comboPoints.d.ts +14 -0
  34. package/dist/combat/comboPoints.js +52 -0
  35. package/dist/combat/defensiveWindow.d.ts +1 -1
  36. package/dist/combat/defensiveWindow.js +5 -3
  37. package/dist/combat/dotField.d.ts +43 -0
  38. package/dist/combat/dotField.js +48 -0
  39. package/dist/combat/projectiles.d.ts +33 -12
  40. package/dist/combat/projectiles.js +214 -129
  41. package/dist/combat/regenShield.d.ts +35 -0
  42. package/dist/combat/regenShield.js +45 -0
  43. package/dist/combat/resistance.d.ts +16 -4
  44. package/dist/combat/resistance.js +43 -7
  45. package/dist/combat/resourcePool.d.ts +20 -0
  46. package/dist/combat/resourcePool.js +55 -0
  47. package/dist/combat/shotOrigin.d.ts +52 -0
  48. package/dist/combat/shotOrigin.js +86 -0
  49. package/dist/combat/telegraph.d.ts +25 -0
  50. package/dist/combat/telegraph.js +47 -0
  51. package/dist/crafting/recipe.js +4 -1
  52. package/dist/devtools/devtools.d.ts +72 -24
  53. package/dist/devtools/devtools.js +449 -101
  54. package/dist/devtools/transformTunables.js +3 -3
  55. package/dist/devtools/tunableSchema.d.ts +140 -0
  56. package/dist/devtools/tunableSchema.js +476 -0
  57. package/dist/editor/commands.d.ts +65 -0
  58. package/dist/editor/commands.js +193 -0
  59. package/dist/editor/document.d.ts +40 -0
  60. package/dist/editor/document.js +153 -0
  61. package/dist/editor/index.d.ts +30 -0
  62. package/dist/editor/index.js +6 -0
  63. package/dist/editor/types.d.ts +69 -0
  64. package/dist/editor/types.js +29 -0
  65. package/dist/faction/factions.d.ts +33 -0
  66. package/dist/faction/factions.js +76 -0
  67. package/dist/faction/reputation.d.ts +32 -0
  68. package/dist/faction/reputation.js +82 -0
  69. package/dist/game/chat.d.ts +2 -0
  70. package/dist/game/chat.js +9 -1
  71. package/dist/game/chatFilter.d.ts +15 -0
  72. package/dist/game/chatFilter.js +55 -0
  73. package/dist/game/connectedPlayers.d.ts +28 -0
  74. package/dist/game/connectedPlayers.js +21 -0
  75. package/dist/game/controlGate.d.ts +4 -0
  76. package/dist/game/controlGate.js +7 -0
  77. package/dist/game/defineGame.d.ts +44 -1
  78. package/dist/game/defineGame.js +1 -0
  79. package/dist/game/events.d.ts +25 -0
  80. package/dist/game/gamePhase.d.ts +20 -0
  81. package/dist/game/gamePhase.js +21 -0
  82. package/dist/game/keyValueStore.d.ts +25 -0
  83. package/dist/game/keyValueStore.js +61 -0
  84. package/dist/game/levelSequence.d.ts +24 -2
  85. package/dist/game/levelSequence.js +51 -2
  86. package/dist/game/lootTable.d.ts +14 -1
  87. package/dist/game/lootTable.js +28 -7
  88. package/dist/game/modelAnimation.d.ts +8 -0
  89. package/dist/game/modelAnimation.js +18 -0
  90. package/dist/game/playableGame.d.ts +116 -4
  91. package/dist/game/playableGame.js +1 -1
  92. package/dist/game/quest.d.ts +7 -3
  93. package/dist/game/quest.js +17 -8
  94. package/dist/game/race.d.ts +39 -1
  95. package/dist/game/race.js +96 -11
  96. package/dist/game/recordBook.d.ts +27 -0
  97. package/dist/game/recordBook.js +86 -0
  98. package/dist/game/talents.d.ts +47 -0
  99. package/dist/game/talents.js +140 -0
  100. package/dist/index.d.ts +8 -0
  101. package/dist/index.js +8 -0
  102. package/dist/input/actionBindings.d.ts +2 -0
  103. package/dist/input/actionBindings.js +1 -0
  104. package/dist/input/axisInput.d.ts +33 -3
  105. package/dist/input/axisInput.js +60 -4
  106. package/dist/input/bindingOverrides.d.ts +25 -0
  107. package/dist/input/bindingOverrides.js +95 -0
  108. package/dist/input/pointerAxis.d.ts +30 -0
  109. package/dist/input/pointerAxis.js +31 -0
  110. package/dist/input/touchScheme.d.ts +6 -0
  111. package/dist/input/touchScheme.js +45 -7
  112. package/dist/interaction/contextMenu.d.ts +1 -0
  113. package/dist/interaction/contextMenu.js +1 -0
  114. package/dist/interaction/proximityPrompt.d.ts +3 -1
  115. package/dist/interaction/proximityPrompt.js +3 -3
  116. package/dist/meta/changelog.d.ts +1 -1
  117. package/dist/meta/changelog.js +44 -1
  118. package/dist/movement/dash.d.ts +3 -0
  119. package/dist/movement/dash.js +19 -5
  120. package/dist/movement/glideModel.d.ts +44 -0
  121. package/dist/movement/glideModel.js +54 -0
  122. package/dist/movement/grappleSwing.d.ts +34 -0
  123. package/dist/movement/grappleSwing.js +56 -0
  124. package/dist/movement/leaderTrail.d.ts +29 -0
  125. package/dist/movement/leaderTrail.js +67 -0
  126. package/dist/movement/movementModel.d.ts +4 -0
  127. package/dist/movement/movementModel.js +13 -0
  128. package/dist/movement/playerMovement.d.ts +41 -0
  129. package/dist/movement/playerMovement.js +181 -0
  130. package/dist/movement/steering.d.ts +27 -0
  131. package/dist/movement/steering.js +40 -0
  132. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  133. package/dist/multiplayer/feedWriteGate.js +12 -0
  134. package/dist/multiplayer/presenceModel.d.ts +14 -2
  135. package/dist/multiplayer/presenceModel.js +15 -3
  136. package/dist/nav/corridors.d.ts +30 -0
  137. package/dist/nav/corridors.js +69 -0
  138. package/dist/nav/navGrid.js +30 -4
  139. package/dist/nav/railGraph.d.ts +66 -0
  140. package/dist/nav/railGraph.js +129 -0
  141. package/dist/nav/timetable.d.ts +41 -0
  142. package/dist/nav/timetable.js +94 -0
  143. package/dist/physics/buoyancy.d.ts +6 -0
  144. package/dist/physics/buoyancy.js +14 -0
  145. package/dist/physics/flowTube.d.ts +32 -0
  146. package/dist/physics/flowTube.js +64 -0
  147. package/dist/physics/forceVolume.d.ts +27 -0
  148. package/dist/physics/forceVolume.js +38 -0
  149. package/dist/physics/kinematicVehicle.d.ts +52 -0
  150. package/dist/physics/kinematicVehicle.js +81 -0
  151. package/dist/physics/physicsWorld.d.ts +13 -6
  152. package/dist/physics/physicsWorld.js +169 -40
  153. package/dist/physics/ragdoll.js +1 -1
  154. package/dist/physics/vehicleBody.js +2 -1
  155. package/dist/random/rng.d.ts +2 -0
  156. package/dist/random/rng.js +2 -0
  157. package/dist/random/seedLink.d.ts +5 -0
  158. package/dist/random/seedLink.js +35 -0
  159. package/dist/render/postProcessing.d.ts +56 -0
  160. package/dist/render/postProcessing.js +1 -0
  161. package/dist/runtime/adapter.d.ts +19 -0
  162. package/dist/runtime/adapter.js +11 -5
  163. package/dist/runtime/cameraDirector.d.ts +6 -1
  164. package/dist/runtime/cameraDirector.js +6 -0
  165. package/dist/runtime/commandRunner.d.ts +2 -2
  166. package/dist/runtime/commandRunner.js +2 -2
  167. package/dist/runtime/gameContext.d.ts +72 -8
  168. package/dist/runtime/gameContext.js +374 -61
  169. package/dist/runtime/gameRuntime.d.ts +5 -1
  170. package/dist/runtime/gameRuntime.js +13 -12
  171. package/dist/runtime/hostPersistence.d.ts +9 -1
  172. package/dist/runtime/hostPersistence.js +23 -8
  173. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  174. package/dist/runtime/hostedGameRunner.js +62 -0
  175. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  176. package/dist/runtime/hostedWorldSession.js +57 -0
  177. package/dist/runtime/inputSnapshot.d.ts +18 -0
  178. package/dist/runtime/inputSnapshot.js +7 -0
  179. package/dist/runtime/motionIntents.d.ts +16 -6
  180. package/dist/runtime/motionIntents.js +31 -2
  181. package/dist/runtime/snapshot.d.ts +1 -0
  182. package/dist/runtime/snapshot.js +1 -1
  183. package/dist/runtime/visibility.d.ts +74 -0
  184. package/dist/runtime/visibility.js +127 -0
  185. package/dist/runtime/worldChannel.d.ts +58 -0
  186. package/dist/runtime/worldChannel.js +69 -0
  187. package/dist/runtime/worldMirror.d.ts +24 -0
  188. package/dist/runtime/worldMirror.js +31 -0
  189. package/dist/runtime/worldReplication.d.ts +46 -0
  190. package/dist/runtime/worldReplication.js +185 -0
  191. package/dist/runtime/worldSnapshot.d.ts +17 -0
  192. package/dist/runtime/worldSnapshot.js +15 -0
  193. package/dist/scene/assetCatalog.d.ts +1 -0
  194. package/dist/scene/colliders.d.ts +50 -0
  195. package/dist/scene/colliders.js +101 -0
  196. package/dist/scene/entityStats.d.ts +4 -0
  197. package/dist/scene/entityStats.js +21 -0
  198. package/dist/scene/entityStore.d.ts +51 -5
  199. package/dist/scene/entityStore.js +119 -12
  200. package/dist/scene/objectQuery.d.ts +13 -2
  201. package/dist/scene/objectQuery.js +34 -2
  202. package/dist/scene/objectStore.d.ts +2 -0
  203. package/dist/scene/objectStore.js +31 -12
  204. package/dist/scene/sceneRaycast.d.ts +80 -0
  205. package/dist/scene/sceneRaycast.js +284 -0
  206. package/dist/scene/spatial.d.ts +11 -16
  207. package/dist/scene/spatial.js +99 -38
  208. package/dist/sensor/recordingBuffer.d.ts +6 -0
  209. package/dist/sensor/recordingBuffer.js +7 -0
  210. package/dist/sensor/replayLoop.d.ts +39 -0
  211. package/dist/sensor/replayLoop.js +64 -0
  212. package/dist/sensor/visionCone.d.ts +31 -0
  213. package/dist/sensor/visionCone.js +72 -0
  214. package/dist/session/extraction.d.ts +2 -2
  215. package/dist/session/extraction.js +13 -2
  216. package/dist/settings/settingsModel.d.ts +97 -0
  217. package/dist/settings/settingsModel.js +100 -0
  218. package/dist/stats/statModifiers.d.ts +5 -2
  219. package/dist/stats/statModifiers.js +32 -14
  220. package/dist/store/observableKeyedStore.d.ts +4 -0
  221. package/dist/store/observableKeyedStore.js +17 -2
  222. package/dist/survival/decayMeter.d.ts +1 -1
  223. package/dist/survival/decayMeter.js +0 -1
  224. package/dist/tactics/fallingGrid.d.ts +2 -2
  225. package/dist/tactics/fallingGrid.js +2 -2
  226. package/dist/time/beatClock.d.ts +18 -0
  227. package/dist/time/beatClock.js +24 -0
  228. package/dist/time/idleProgress.d.ts +29 -0
  229. package/dist/time/idleProgress.js +31 -0
  230. package/dist/time/serverTick.d.ts +7 -1
  231. package/dist/time/serverTick.js +27 -4
  232. package/dist/time/stateSchedule.d.ts +57 -0
  233. package/dist/time/stateSchedule.js +121 -0
  234. package/dist/turn/commit.d.ts +2 -2
  235. package/dist/turn/commit.js +2 -2
  236. package/dist/ui/gameLayout.d.ts +125 -0
  237. package/dist/ui/gameLayout.js +129 -0
  238. package/dist/ui/hudLayout.d.ts +58 -0
  239. package/dist/ui/hudLayout.js +207 -0
  240. package/dist/ui/hudScale.d.ts +44 -0
  241. package/dist/ui/hudScale.js +55 -0
  242. package/dist/ui/orientation.d.ts +39 -0
  243. package/dist/ui/orientation.js +49 -0
  244. package/dist/ui/swingTimer.d.ts +42 -0
  245. package/dist/ui/swingTimer.js +38 -0
  246. package/dist/visibility/assetStreaming.d.ts +66 -0
  247. package/dist/visibility/assetStreaming.js +201 -0
  248. package/dist/visibility/bounds.d.ts +66 -0
  249. package/dist/visibility/bounds.js +113 -0
  250. package/dist/visibility/camera.d.ts +21 -0
  251. package/dist/visibility/camera.js +3 -0
  252. package/dist/visibility/config.d.ts +21 -0
  253. package/dist/visibility/config.js +1 -0
  254. package/dist/visibility/diagnostics.d.ts +26 -0
  255. package/dist/visibility/diagnostics.js +36 -0
  256. package/dist/visibility/distance.d.ts +11 -0
  257. package/dist/visibility/distance.js +30 -0
  258. package/dist/visibility/frustum.d.ts +47 -0
  259. package/dist/visibility/frustum.js +186 -0
  260. package/dist/visibility/occlusion.d.ts +36 -0
  261. package/dist/visibility/occlusion.js +45 -0
  262. package/dist/visibility/settings.d.ts +75 -0
  263. package/dist/visibility/settings.js +49 -0
  264. package/dist/visibility/simulationCulling.d.ts +34 -0
  265. package/dist/visibility/simulationCulling.js +36 -0
  266. package/dist/visibility/spatialIndex.d.ts +45 -0
  267. package/dist/visibility/spatialIndex.js +212 -0
  268. package/dist/visibility/visibilitySystem.d.ts +90 -0
  269. package/dist/visibility/visibilitySystem.js +257 -0
  270. package/dist/world/buildings.d.ts +6 -0
  271. package/dist/world/buildings.js +152 -0
  272. package/dist/world/carve.d.ts +7 -2
  273. package/dist/world/carve.js +17 -7
  274. package/dist/world/cellStates.d.ts +45 -0
  275. package/dist/world/cellStates.js +103 -0
  276. package/dist/world/environmentSummary.d.ts +21 -4
  277. package/dist/world/environmentSummary.js +22 -3
  278. package/dist/world/features.d.ts +185 -8
  279. package/dist/world/features.js +64 -6
  280. package/dist/world/gridInstances.d.ts +7 -8
  281. package/dist/world/gridInstances.js +145 -7
  282. package/dist/world/lod.d.ts +17 -0
  283. package/dist/world/lod.js +66 -0
  284. package/dist/world/mapLayers.d.ts +60 -0
  285. package/dist/world/mapLayers.js +44 -0
  286. package/dist/world/massing.d.ts +45 -0
  287. package/dist/world/massing.js +581 -0
  288. package/dist/world/minimap.d.ts +13 -1
  289. package/dist/world/minimap.js +21 -2
  290. package/dist/world/roads.d.ts +47 -0
  291. package/dist/world/roads.js +146 -0
  292. package/dist/world/streets.d.ts +80 -0
  293. package/dist/world/streets.js +144 -0
  294. package/dist/world/terrain.d.ts +69 -3
  295. package/dist/world/terrain.js +309 -16
  296. package/dist/world/water.d.ts +7 -0
  297. package/dist/world/water.js +19 -4
  298. package/dist/world/weather.d.ts +3 -18
  299. package/dist/world/weather.js +4 -17
  300. package/dist/world/windZones.d.ts +47 -0
  301. package/dist/world/windZones.js +59 -0
  302. package/llms.txt +3013 -1726
  303. package/package.json +12 -2
@@ -1,3 +1,4 @@
1
+ import { createKeyValueStore } from "./keyValueStore.js";
1
2
  /**
2
3
  * A pure, deterministic level campaign: an ordered list of levels, each with its own opaque config, played
3
4
  * through a `start` → (`clear` → `advance`)* → `complete` happy path, with `fail`/`retry` handling per-level
@@ -7,10 +8,32 @@
7
8
  export function createLevelSequence(config) {
8
9
  const levels = config.levels;
9
10
  const maxAttempts = 1 + Math.max(0, Math.floor(config.retriesPerLevel ?? 0));
11
+ const store = config.key === undefined
12
+ ? null
13
+ : createKeyValueStore({
14
+ key: config.key,
15
+ initial: {},
16
+ storage: config.storage,
17
+ });
10
18
  let index = 0;
11
19
  let attempt = 1;
12
20
  let status = "idle";
13
21
  const clearedIds = [];
22
+ const levelRecords = store === null ? {} : { ...store.get() };
23
+ function persistRecords() {
24
+ store?.set({ ...levelRecords });
25
+ }
26
+ function indexOfLevel(id) {
27
+ return levels.findIndex((level) => level.id === id);
28
+ }
29
+ function isUnlocked(id) {
30
+ const idx = indexOfLevel(id);
31
+ if (idx < 0)
32
+ return false;
33
+ if (idx === 0)
34
+ return true;
35
+ return levelRecords[levels[idx - 1].id]?.cleared === true;
36
+ }
14
37
  function current() {
15
38
  if (status === "idle" || status === "complete")
16
39
  return null;
@@ -30,12 +53,38 @@ export function createLevelSequence(config) {
30
53
  clearedIds.length = 0;
31
54
  status = "playing";
32
55
  },
33
- clear() {
56
+ clear(stars) {
34
57
  if (status !== "playing" || current() === null)
35
58
  return;
36
- clearedIds.push(levels[index].id);
59
+ const id = levels[index].id;
60
+ clearedIds.push(id);
61
+ const previousStars = levelRecords[id]?.stars ?? 0;
62
+ const bestStars = Math.max(previousStars, stars ?? 0);
63
+ levelRecords[id] = { cleared: true, stars: bestStars };
64
+ persistRecords();
37
65
  status = "cleared";
38
66
  },
67
+ select(level) {
68
+ const idx = indexOfLevel(level);
69
+ if (idx < 0 || !isUnlocked(level))
70
+ return false;
71
+ index = idx;
72
+ attempt = 1;
73
+ status = "playing";
74
+ return true;
75
+ },
76
+ isUnlocked,
77
+ record: (level) => levelRecords[level] ?? null,
78
+ records: () => ({ ...levelRecords }),
79
+ clearRecords() {
80
+ for (const id of Object.keys(levelRecords))
81
+ delete levelRecords[id];
82
+ store?.clear();
83
+ index = 0;
84
+ attempt = 1;
85
+ clearedIds.length = 0;
86
+ status = "idle";
87
+ },
39
88
  fail() {
40
89
  if (status !== "playing" || current() === null)
41
90
  return "failed";
@@ -1,14 +1,26 @@
1
+ /** One possible drop in a {@link LootTableDef} — an item or currency, its count range, and its odds. */
1
2
  export interface LootEntry {
2
3
  item?: string;
3
4
  currency?: string;
4
5
  count: number | [number, number];
5
- weight: number;
6
+ /** Relative pick weight — required in `"weighted"` mode, forbidden in `"independent"` mode. */
7
+ weight?: number;
8
+ /** Per-entry drop probability in `(0, 1]` — required in `"independent"` mode, forbidden in `"weighted"` mode. */
9
+ chance?: number;
6
10
  }
11
+ /** A named, validated loot table — its roll count, weighted-vs-independent mode, and candidate entries. */
7
12
  export interface LootTableDef {
8
13
  id: string;
9
14
  rolls?: number;
15
+ /**
16
+ * `"weighted"` (default): each roll picks exactly one entry by relative `weight`.
17
+ * `"independent"`: each roll gives every entry its own `chance` — classic drop lists
18
+ * where a kill can yield several items (or nothing) without filler entries.
19
+ */
20
+ mode?: "weighted" | "independent";
10
21
  entries: LootEntry[];
11
22
  }
23
+ /** A resolved loot outcome — one item or currency grant with its rolled count. */
12
24
  export interface Drop {
13
25
  item?: string;
14
26
  currency?: string;
@@ -20,6 +32,7 @@ export interface LootRegistry {
20
32
  roll(id: string, rng?: () => number): Drop[];
21
33
  }
22
34
  export declare function createLootRegistry(): LootRegistry;
35
+ /** Validates a loot table definition and returns it unchanged, for use with {@link createLootRegistry}. */
23
36
  export declare function lootTable(def: LootTableDef): LootTableDef;
24
37
  export declare function grantDrops(drops: Drop[], appliers: {
25
38
  putItem: (itemId: string, count: number) => unknown;
@@ -1,11 +1,23 @@
1
- function assertValidEntry(entry) {
1
+ function assertValidEntry(entry, mode) {
2
2
  const hasItem = entry.item !== undefined;
3
3
  const hasCurrency = entry.currency !== undefined;
4
4
  if (hasItem === hasCurrency) {
5
5
  throw new Error("loot entry must have exactly one of item or currency");
6
6
  }
7
- if (!(entry.weight > 0)) {
8
- throw new Error(`loot entry weight must be positive, got ${entry.weight}`);
7
+ if (mode === "weighted") {
8
+ if (entry.chance !== undefined) {
9
+ throw new Error("loot entry chance is only valid in independent mode");
10
+ }
11
+ if (entry.weight === undefined || !(entry.weight > 0)) {
12
+ throw new Error(`loot entry weight must be positive, got ${entry.weight}`);
13
+ }
14
+ return;
15
+ }
16
+ if (entry.weight !== undefined) {
17
+ throw new Error("loot entry weight is only valid in weighted mode");
18
+ }
19
+ if (entry.chance === undefined || !(entry.chance > 0) || entry.chance > 1) {
20
+ throw new Error(`loot entry chance must be in (0, 1], got ${entry.chance}`);
9
21
  }
10
22
  }
11
23
  function assertValidDef(def) {
@@ -13,7 +25,7 @@ function assertValidDef(def) {
13
25
  throw new Error(`loot table "${def.id}" must have at least one entry`);
14
26
  }
15
27
  for (const entry of def.entries)
16
- assertValidEntry(entry);
28
+ assertValidEntry(entry, def.mode ?? "weighted");
17
29
  }
18
30
  function resolveCount(count, rng) {
19
31
  if (typeof count === "number")
@@ -22,10 +34,10 @@ function resolveCount(count, rng) {
22
34
  return min + Math.floor(rng() * (max - min + 1));
23
35
  }
24
36
  function pickEntry(entries, rng) {
25
- const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
37
+ const total = entries.reduce((sum, entry) => sum + (entry.weight ?? 0), 0);
26
38
  let roll = rng() * total;
27
39
  for (const entry of entries) {
28
- roll -= entry.weight;
40
+ roll -= entry.weight ?? 0;
29
41
  if (roll < 0)
30
42
  return entry;
31
43
  }
@@ -56,12 +68,21 @@ export function createLootRegistry() {
56
68
  const rolls = table.rolls ?? 1;
57
69
  const drops = [];
58
70
  for (let i = 0; i < rolls; i++) {
59
- drops.push(rollEntry(pickEntry(table.entries, rng), rng));
71
+ if (table.mode === "independent") {
72
+ for (const entry of table.entries) {
73
+ if (rng() < (entry.chance ?? 0))
74
+ drops.push(rollEntry(entry, rng));
75
+ }
76
+ }
77
+ else {
78
+ drops.push(rollEntry(pickEntry(table.entries, rng), rng));
79
+ }
60
80
  }
61
81
  return drops;
62
82
  },
63
83
  };
64
84
  }
85
+ /** Validates a loot table definition and returns it unchanged, for use with {@link createLootRegistry}. */
65
86
  export function lootTable(def) {
66
87
  assertValidDef(def);
67
88
  return def;
@@ -0,0 +1,8 @@
1
+ /** The two one-shot events the shell fires automatically for an entity, from `combat.hitReaction` and `entity.died`. */
2
+ export declare const AUTO_ONE_SHOT_EVENTS: readonly ["hit", "death"];
3
+ /**
4
+ * Resolves the clip name a one-shot `event` should play from a model's `animation.oneShots` map, or `null`
5
+ * if the event isn't bound. A `string[]` binding picks a variant by `roll` (a value in `[0, 1)`), so combat
6
+ * can vary attack swings. Pure and deterministic given `roll` — the shell supplies the randomness.
7
+ */
8
+ export declare function resolveOneShotClip(oneShots: Record<string, string | readonly string[]> | undefined, event: string, roll: number): string | null;
@@ -0,0 +1,18 @@
1
+ /** The two one-shot events the shell fires automatically for an entity, from `combat.hitReaction` and `entity.died`. */
2
+ export const AUTO_ONE_SHOT_EVENTS = ["hit", "death"];
3
+ /**
4
+ * Resolves the clip name a one-shot `event` should play from a model's `animation.oneShots` map, or `null`
5
+ * if the event isn't bound. A `string[]` binding picks a variant by `roll` (a value in `[0, 1)`), so combat
6
+ * can vary attack swings. Pure and deterministic given `roll` — the shell supplies the randomness.
7
+ */
8
+ export function resolveOneShotClip(oneShots, event, roll) {
9
+ const spec = oneShots?.[event];
10
+ if (spec === undefined)
11
+ return null;
12
+ if (typeof spec === "string")
13
+ return spec;
14
+ if (spec.length === 0)
15
+ return null;
16
+ const index = Math.min(spec.length - 1, Math.max(0, Math.floor(roll * spec.length)));
17
+ return spec[index] ?? null;
18
+ }
@@ -1,9 +1,15 @@
1
1
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
2
+ import type { MusicTheme } from "../audio/music.js";
3
+ import type { PostProcessingConfig } from "../render/postProcessing.js";
2
4
  import type { TouchControlsConfig } from "../input/touchScheme.js";
5
+ import type { GameSettingsConfig } from "../settings/settingsModel.js";
6
+ import type { GameOrientation } from "../ui/orientation.js";
7
+ import type { HudPlatform, HudViewportConfig } from "../ui/hudScale.js";
3
8
  import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
4
9
  import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext.js";
5
10
  import type { ModelDims } from "../scene/assetCatalog.js";
6
11
  import type { SkyEnvironmentConfig } from "../world/features.js";
12
+ import type { VisibilityConfig } from "../visibility/config.js";
7
13
  import type { GameDefinition, GameLoop } from "./defineGame.js";
8
14
  import type { LootFilterRule } from "./lootFilter.js";
9
15
  import type { RarityStyle } from "./worldItem.js";
@@ -75,6 +81,8 @@ export interface FirstPersonCameraConfig {
75
81
  * - `none` — no camera rig is mounted; use for HUD-only presentations or a game that manages its own camera.
76
82
  */
77
83
  export type CameraRigKind = "orbit" | "first" | "topDown" | "rts" | "shoulder" | "lockOn" | "chase" | "observer" | "turntable" | "sideScroll" | "inspection" | "none";
84
+ /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default "perspective". */
85
+ export type CameraProjection = "perspective" | "orthographic";
78
86
  /** Fixed lateral 2.5D follow (side-on platformer cam): the camera sits perpendicular to the travel axis, tracks the followed entity, and never reads player look input. */
79
87
  export interface SideScrollCameraConfig {
80
88
  /** World axis the action travels along; the camera watches from the perpendicular side. Default "x". */
@@ -202,6 +210,17 @@ export interface ChaseCameraConfig {
202
210
  };
203
211
  /** Procedural shake amplitude per unit of speed (adds to the trauma channel). Default 0.0. */
204
212
  shakePerSpeed?: number;
213
+ /** Velocity-lead / predictive follow (#286.9): the rig aims `time` seconds ahead of the target along its velocity, clamped to `max` world units (default 4). */
214
+ lead?: {
215
+ time: number;
216
+ max?: number;
217
+ };
218
+ /** Roll into turns (#286.10): radians of camera roll per rad/s of the target's yaw rate, clamped to `max` (default 0.35), exponentially smoothed by `damping` (default 8). */
219
+ bank?: {
220
+ perYawRate: number;
221
+ max?: number;
222
+ damping?: number;
223
+ };
205
224
  /** Which view to mount. Default "chase". */
206
225
  view?: ChaseView;
207
226
  /** Local offset for cockpit/hood/rear seats (relative to the vehicle, +z forward). */
@@ -354,16 +373,35 @@ export declare const CAMERA_FRUSTUM_DEFAULTS: {
354
373
  readonly fov: 55;
355
374
  readonly near: 0.1;
356
375
  readonly far: 300;
376
+ readonly zoom: 50;
357
377
  };
378
+ /** Player-facing FOV preference applied across every perspective camera rig. Orthographic projections ignore it. */
379
+ export interface PlayerFovConfig {
380
+ /** Inclusive lower bound for the slider and persisted values. Default 40. */
381
+ min?: number;
382
+ /** Inclusive upper bound for the slider and persisted values. Default 120. */
383
+ max?: number;
384
+ /** Initial FOV when nothing is persisted. Defaults to `frustum.fov` or 55. */
385
+ default?: number;
386
+ /** Persist the preference in localStorage across reloads. Default true. */
387
+ persist?: boolean;
388
+ /** Show the shell FOV slider. Default true for perspective cameras; forced off for orthographic. */
389
+ control?: boolean;
390
+ }
358
391
  export interface GameCameraConfig {
359
392
  /** Selects the rig. Overrides `perspective`; leave unset to fall back to `perspective`. */
360
393
  rig?: CameraRigKind;
361
- /** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. */
394
+ /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default perspective. */
395
+ projection?: CameraProjection;
396
+ /** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. `zoom` is the orthographic zoom in canvas pixels per world unit, read only when `projection` is "orthographic"; default 50. */
362
397
  frustum?: {
363
398
  fov?: number;
364
399
  near?: number;
365
400
  far?: number;
401
+ zoom?: number;
366
402
  };
403
+ /** Universal player FOV preference (slider + persistence) for perspective rigs. Ignored when `projection` is `"orthographic"`. */
404
+ playerFov?: PlayerFovConfig;
367
405
  /** "third" mounts the orbit camera (default); "first" mounts pointer-lock mouse-look. Shorthand for `rig: "orbit" | "first"`. */
368
406
  perspective?: "third" | "first";
369
407
  /** First-person tuning; only read when the rig resolves to "first". */
@@ -420,6 +458,12 @@ export interface GameCameraConfig {
420
458
  /** Vertical look clamp (three.js polar angle, radians): 0 = top-down over the head, PI/2 = level, >PI/2 = look up from below. Widen for top-down or vertical aim; unset keeps the standard chase feel. */
421
459
  minPolarAngle?: number;
422
460
  maxPolarAngle?: number;
461
+ /** Spring-arm occlusion for the orbit rig: pull the camera in past walls/terrain so it never clips inside geometry. Off unless `enabled`. */
462
+ collision?: {
463
+ enabled?: boolean;
464
+ padding?: number;
465
+ minTargetDistance?: number;
466
+ };
423
467
  }
424
468
  export interface EntitySpriteConfig {
425
469
  url: string;
@@ -457,6 +501,21 @@ export interface VoxelCollisionConfig {
457
501
  /** Tallest ledge walked up without jumping. Default 0.6. */
458
502
  stepHeight?: number;
459
503
  }
504
+ /** Movement-state clip set for `ModelAnimationConfig.states`: the shell reads the entity's live speed each frame and crossfades between these clips, so a walking mob animates without any game-side driver. */
505
+ export interface ModelAnimationStates {
506
+ /** Clip name while the entity is stationary. */
507
+ idle: string;
508
+ /** Clip name while the entity is moving. */
509
+ walk: string;
510
+ /** Clip name above `runSpeed`; omit to keep `walk` at any speed. */
511
+ run?: string;
512
+ /** Speed (world units/sec) above which the entity counts as moving. Default 0.5. */
513
+ walkSpeed?: number;
514
+ /** Speed above which `run` plays when provided. Default 6. */
515
+ runSpeed?: number;
516
+ /** Crossfade duration in seconds when the state changes. Default 0.2. */
517
+ fadeSec?: number;
518
+ }
460
519
  /** Rig playback for a `ModelConfig`'s GLTF animation clips — looping idles, one-shots, and held poses. */
461
520
  export interface ModelAnimationConfig {
462
521
  /** Clip name to play; defaults to the GLB's first clip. */
@@ -469,6 +528,15 @@ export interface ModelAnimationConfig {
469
528
  paused?: boolean;
470
529
  /** Seek the clip to this time in seconds; combine with `paused: true` to hold a specific pose ("pose library" usage). */
471
530
  time?: number;
531
+ /** Speed-driven idle/walk/run clip switching, crossfaded by the shell from the entity's live movement; overrides `clip` while set. */
532
+ states?: ModelAnimationStates;
533
+ /**
534
+ * One-shot clips keyed by event name, each played once over the locomotion state then released back to it.
535
+ * Reserved keys `hit` and `death` auto-fire on this entity's `combat.hitReaction` / `entity.died`; any other
536
+ * key fires when the game emits `entity.animation` (`ctx.game.playEntityAnimation(instanceId, event)`). A
537
+ * `string[]` picks a random variant per trigger. `death` clamps on its final frame instead of returning.
538
+ */
539
+ oneShots?: Record<string, string | readonly string[]>;
472
540
  }
473
541
  /** Per-entity PBR material override (#151.3) applied to every `MeshStandardMaterial` in the model's cloned scene graph. */
474
542
  export interface ModelMaterialOverride {
@@ -478,9 +546,24 @@ export interface ModelMaterialOverride {
478
546
  emissive?: string;
479
547
  emissiveIntensity?: number;
480
548
  }
549
+ /** Parents a prop/weapon model to a named bone or node on the host model's rig — a sword on `handslot.r`, a spellbook offhand — following the bone's animated transform each frame. */
550
+ export interface ModelAttachment {
551
+ /** Bone or node name in the rig to parent to (e.g. `"handslot.r"`). */
552
+ slot: string;
553
+ /** The attached model — a catalog asset id or an inline `ModelConfig`. */
554
+ model: string | ModelConfig;
555
+ /** Local position offset at the bone, in the attached model's own space. */
556
+ position?: [number, number, number];
557
+ /** Local Euler rotation (radians) applied at the bone. */
558
+ rotation?: [number, number, number];
559
+ /** Uniform scale of the attached model at the bone. Default 1. */
560
+ scale?: number;
561
+ }
481
562
  export interface ModelConfig {
482
563
  url: string;
483
564
  scale?: number;
565
+ /** Normalize the rendered model to this world-unit height: the shell measures the loaded scene's bounding box, scales it so its height matches, and grounds its lowest point at the placement Y. Composes with `scale` as a multiplier. */
566
+ targetHeight?: number;
484
567
  y?: number;
485
568
  /** How the model registers on its placement point. `"center"` (default) horizontally centers the measured footprint on the point and ground-snaps its lowest vertex to the point's Y — the correct behavior for corner-pivot modular kits. `"origin"` renders at the raw GLB origin (legacy). */
486
569
  anchor?: "center" | "origin";
@@ -490,6 +573,8 @@ export interface ModelConfig {
490
573
  material?: ModelMaterialOverride;
491
574
  /** Plays a GLTF animation clip on the model when the source has any (skinned or not); omit to render the rig's bind pose. */
492
575
  animation?: ModelAnimationConfig;
576
+ /** Props/weapons parented to named bones on this model's rig; each follows its bone through animation. */
577
+ attachments?: readonly ModelAttachment[];
493
578
  }
494
579
  export interface ObjectStyle {
495
580
  color?: string;
@@ -505,6 +590,14 @@ export interface DirectionalLightingConfig {
505
590
  intensity?: number;
506
591
  position: readonly [number, number, number];
507
592
  castShadow?: boolean;
593
+ /** Shadow map resolution in px (square) when `castShadow`. Higher = crisper, costlier. Default 1024. */
594
+ shadowMapSize?: number;
595
+ /** Half-extent of the orthographic shadow camera in world units — sized to the shadowed area. Default 40. */
596
+ shadowCameraSize?: number;
597
+ /** Shadow depth bias to fight acne. Default -0.0004. */
598
+ shadowBias?: number;
599
+ /** Shadow normal bias to fight peter-panning. Default 0.02. */
600
+ shadowNormalBias?: number;
508
601
  }
509
602
  export interface HemisphereLightingConfig {
510
603
  skyColor?: string;
@@ -542,6 +635,8 @@ export interface PlayerMovementConfig {
542
635
  collideObjects?: boolean;
543
636
  /** Intercepts each frame's resolved position before the pose commits (and before onTick): return a replacement [x,y,z] to constrain or redirect the step, or nothing to accept it. */
544
637
  beforeCommit?: (frame: MovementCommitFrame) => readonly [number, number, number] | undefined | void;
638
+ /** Gates the built-in sprint (`runSpeedMultiplier`) behind live game state — a stamina stat, an encumbrance check (#282.3). Called each frame while sprint is held; `false` walks. */
639
+ canSprint?: (ctx: GameContext) => boolean;
545
640
  }
546
641
  /** One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`. */
547
642
  export interface MovementCommitFrame {
@@ -549,11 +644,13 @@ export interface MovementCommitFrame {
549
644
  current: readonly [number, number, number];
550
645
  next: readonly [number, number, number];
551
646
  dt: number;
647
+ /** The live game context (#282.2) — movement rules read stores/stats directly instead of module-level bridge objects. */
648
+ ctx: GameContext;
552
649
  }
553
650
  export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEntity = never, TRenderObject = never> {
554
651
  game: GameDefinition;
555
652
  content: GameContextContent;
556
- loop: Required<GameLoop<GameContext>>;
653
+ loop: Required<Omit<GameLoop<GameContext>, "onPlayerLeave">> & Pick<GameLoop<GameContext>, "onPlayerLeave">;
557
654
  GameUI: TUi;
558
655
  /** Which shell mount to use. Default `"3d"` (canvas, camera rig, pointer, world rendering). `"hud"` mounts no 3D canvas, camera rig, or pointer — the game is `GameUI` plus the command/input loop, for board/card/menu games. */
559
656
  presentation?: "3d" | "hud";
@@ -585,15 +682,24 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
585
682
  pointer?: PointerConfig;
586
683
  /** Touch controls on coarse-pointer devices. Unset derives a scheme from `input` (virtual joystick for movement actions, on-screen buttons for the rest); a config refines it with gestures and curated buttons; `false` opts out. */
587
684
  touch?: TouchControlsConfig | false;
588
- /** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles. */
685
+ /** Phone orientation contract. Legacy `"landscape"`/`"portrait"` stays advisory (a dismissible rotate hint). The object form `{ mobile: "landscape-required" }` is strict — the shell shows an engine-owned rotate screen and blocks gameplay until the device is turned. See `GameOrientation`. */
686
+ orientation?: GameOrientation;
687
+ /** Where the game is meant to be played. Default `["web", "mobile"]` — design-resolution HUD fit is on for every game: `HudCanvas` auto-scales from `hudFit.designSize` down to the live viewport, so the desktop layout shrinks to fit a phone instead of overflowing it. Declare `["web"]` to opt a desktop-only game out (compact displays fall back to the legacy fixed 0.85 zoom). */
688
+ platforms?: readonly HudPlatform[];
689
+ /** HUD design resolution + scale clamps (default 1600×900, scale 0.4–1). `mobile` overrides tune the phone fit separately — the same resolution system drives desktop UI-scale and phone shrink. */
690
+ hudFit?: HudViewportConfig;
691
+ /** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the player (default 60). */
589
692
  worldHealthBars?: boolean | {
590
693
  statId?: string;
591
694
  roles?: readonly CatalogEntityRole[];
695
+ maxDistance?: number;
592
696
  };
593
- /** Sound catalog + mix buses (music/sfx/ambient/…) the shell's Web Audio glue plays from. Catalog-first — no per-game audio wiring. */
697
+ /** Sound catalog + mix buses (music/sfx/ambient/…) the shell's Web Audio glue plays from. Catalog-first — no per-game audio wiring. `sounds` may be sample (`url`) or procedural (`synth`); `music` holds procedural themes crossfaded via `ctx.game.audio.music(id)`. */
594
698
  audio?: {
595
699
  sounds: Record<string, SoundDef>;
596
700
  buses?: Record<string, AudioBusDef>;
701
+ music?: Record<string, MusicTheme>;
702
+ musicBus?: string;
597
703
  };
598
704
  /** Continuous positional emitter keyed by entity kind name: while a matching entity exists, the shell plays and repositions the mapped `audio.sounds` id (looping engine hum, campfire crackle, footstep loop) with listener-distance falloff. */
599
705
  entitySounds?: Record<string, string>;
@@ -609,7 +715,13 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
609
715
  lighting?: LightingConfig;
610
716
  /** Generic background/sky/fog (#207.6), applied for any world kind including a custom `environment` component. */
611
717
  backdrop?: BackdropConfig;
718
+ /** Declarative post-processing chain (AO/bloom/tone-map/grade). When set, the shell mounts an EffectComposer and owns the render; absent leaves the renderer drawing directly (unchanged). */
719
+ postProcessing?: PostProcessingConfig;
612
720
  /** F2 debug overlay (frame/sim timing, logs, backend latency, keybinds, live tunables). On for every game by default; `false` disables the toggle. */
613
721
  devtools?: boolean;
722
+ /** Player settings menu. Auto-mounted for every game (Sound / Graphics / Gameplay / Controls); unset uses the defaults, `false` opts out. Add game-specific rows via `extra`, switch overlay/full-page via `mode`, or swap the gear for compact on-screen buttons via `surface: "quick"`. */
723
+ settings?: GameSettingsConfig | false;
724
+ /** Automatic camera frustum + distance culling and asset streaming. On by default with conservative margins; unset uses engine defaults, `{ enabled: false }` opts out. Override bounds/distance/pins per entity kind or object catalog id. */
725
+ visibility?: VisibilityConfig;
614
726
  }
615
727
  export declare function worldHealthBarAllowsRole(roles: readonly CatalogEntityRole[] | undefined, role: CatalogEntityRole | undefined): boolean;
@@ -1,4 +1,4 @@
1
- export const CAMERA_FRUSTUM_DEFAULTS = { fov: 55, near: 0.1, far: 300 };
1
+ export const CAMERA_FRUSTUM_DEFAULTS = { fov: 55, near: 0.1, far: 300, zoom: 50 };
2
2
  export function worldHealthBarAllowsRole(roles, role) {
3
3
  if (roles === undefined)
4
4
  return true;
@@ -65,7 +65,7 @@ export interface QuestJournalDeps {
65
65
  partyMembersNear?(userId: string, radius: number): string[];
66
66
  }
67
67
  export interface QuestJournal {
68
- register(catalog: QuestDef[] | Record<string, QuestDef>): void;
68
+ register(catalog: readonly QuestDef[] | Record<string, QuestDef>): void;
69
69
  has(questId: string): boolean;
70
70
  canAccept(userId: string, questId: string): {
71
71
  reason: string;
@@ -126,7 +126,11 @@ export interface QuestEvaluator {
126
126
  export declare function applyQuestRewards(rewards: QuestRewards, appliers: {
127
127
  grantXp?(amount: number): void;
128
128
  grantEconomy?(currencyId: string, amount: number): void;
129
- grantItem?(inventoryId: string, itemId: string, count: number): void;
129
+ grantItem?(inventoryId: string, itemId: string, count: number): {
130
+ reason: string;
131
+ } | null | void;
130
132
  grantUnlock?(unlockId: string): void;
131
- }): void;
133
+ }): {
134
+ reason: string;
135
+ } | null;
132
136
  export declare function createQuestEvaluator(defs: QuestDef[] | Record<string, QuestDef>): QuestEvaluator;
@@ -72,17 +72,20 @@ export function createQuestJournal(deps) {
72
72
  return null;
73
73
  }
74
74
  function applyRewards(userId, rewards) {
75
+ for (const entry of rewards.items ?? []) {
76
+ const fail = deps.rewards.grantItem(userId, entry.inventory, entry.item, entry.count);
77
+ if (fail !== null)
78
+ return fail;
79
+ }
75
80
  if (rewards.xp)
76
81
  deps.rewards.grantXp(userId, rewards.xp.amount);
77
82
  for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
78
83
  deps.rewards.grantEconomy(userId, currencyId, amount);
79
84
  }
80
- for (const entry of rewards.items ?? []) {
81
- deps.rewards.grantItem(userId, entry.inventory, entry.item, entry.count);
82
- }
83
85
  for (const unlockId of rewards.unlocks ?? []) {
84
86
  deps.rewards.grantUnlock(userId, unlockId);
85
87
  }
88
+ return null;
86
89
  }
87
90
  function turnIn(userId, questId) {
88
91
  const denied = canTurnIn(userId, questId);
@@ -90,9 +93,12 @@ export function createQuestJournal(deps) {
90
93
  return denied;
91
94
  const def = catalog.get(questId);
92
95
  const state = users.get(userId).get(questId);
96
+ if (def.rewards) {
97
+ const fail = applyRewards(userId, def.rewards);
98
+ if (fail !== null)
99
+ return fail;
100
+ }
93
101
  state.status = "completed";
94
- if (def.rewards)
95
- applyRewards(userId, def.rewards);
96
102
  deps.events.emit("quest.completed", { userId, questId });
97
103
  for (const nextQuestId of def.rewards?.quests ?? []) {
98
104
  if (canAccept(userId, nextQuestId) === null)
@@ -227,17 +233,20 @@ export function createQuestJournal(deps) {
227
233
  };
228
234
  }
229
235
  export function applyQuestRewards(rewards, appliers) {
236
+ for (const entry of rewards.items ?? []) {
237
+ const fail = appliers.grantItem?.(entry.inventory, entry.item, entry.count);
238
+ if (fail != null && typeof fail === "object" && "reason" in fail)
239
+ return fail;
240
+ }
230
241
  if (rewards.xp)
231
242
  appliers.grantXp?.(rewards.xp.amount);
232
243
  for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
233
244
  appliers.grantEconomy?.(currencyId, amount);
234
245
  }
235
- for (const entry of rewards.items ?? []) {
236
- appliers.grantItem?.(entry.inventory, entry.item, entry.count);
237
- }
238
246
  for (const unlockId of rewards.unlocks ?? []) {
239
247
  appliers.grantUnlock?.(unlockId);
240
248
  }
249
+ return null;
241
250
  }
242
251
  function toWorking(state) {
243
252
  const working = new Map();
@@ -3,18 +3,36 @@ export interface Checkpoint {
3
3
  center: readonly [number, number, number];
4
4
  half: readonly [number, number, number];
5
5
  }
6
+ export interface RaceForkRoute {
7
+ id: string;
8
+ checkpoints: readonly Checkpoint[];
9
+ }
10
+ /**
11
+ * An alternate-route section (#286.3): after passing mainline checkpoint `afterIndex`, a racer commits
12
+ * to whichever route's first checkpoint they hit, runs its checkpoints in order, and rejoins at
13
+ * mainline `afterIndex + 1`. Every completed route contributes exactly one checkpoint of `progress`
14
+ * regardless of length, and each hit records a split — so route time accounting comes for free.
15
+ */
16
+ export interface RaceFork {
17
+ id: string;
18
+ /** Mainline checkpoint index the fork opens after; must be before the finish line. */
19
+ afterIndex: number;
20
+ routes: readonly RaceForkRoute[];
21
+ }
6
22
  export interface RaceTrackConfig {
7
23
  checkpoints: readonly Checkpoint[];
8
24
  laps?: number;
25
+ forks?: readonly RaceFork[];
9
26
  }
10
27
  export interface RaceTrack {
11
28
  readonly checkpoints: readonly Checkpoint[];
12
29
  readonly laps: number;
30
+ readonly forks: readonly RaceFork[];
13
31
  }
14
32
  /**
15
33
  * A race track is an ordered ring of checkpoint trigger volumes plus a lap count. The final checkpoint
16
34
  * is the lap/finish line: a racer completes a lap by passing all checkpoints in order and hitting the
17
- * last one.
35
+ * last one. `forks` splice alternate route segments between mainline checkpoints.
18
36
  */
19
37
  export declare function raceTrack(config: RaceTrackConfig): RaceTrack;
20
38
  export interface RacerProgress {
@@ -28,6 +46,13 @@ export interface RacerProgress {
28
46
  finishTime: number | null;
29
47
  eliminated: boolean;
30
48
  splits: readonly number[];
49
+ /** Fork id → route id last taken through it. */
50
+ routesTaken: Readonly<Record<string, string>>;
51
+ /** Route the racer is currently inside, or `null` on the mainline. */
52
+ activeRoute: {
53
+ forkId: string;
54
+ routeId: string;
55
+ } | null;
31
56
  }
32
57
  export type RaceEvent = {
33
58
  type: "checkpoint.hit";
@@ -35,6 +60,18 @@ export type RaceEvent = {
35
60
  checkpoint: number;
36
61
  lap: number;
37
62
  time: number;
63
+ /** Set when the hit is a fork-route checkpoint (`checkpoint` is then the fork's `afterIndex`). */
64
+ fork?: {
65
+ forkId: string;
66
+ routeId: string;
67
+ index: number;
68
+ };
69
+ } | {
70
+ type: "fork.taken";
71
+ racerId: string;
72
+ forkId: string;
73
+ routeId: string;
74
+ time: number;
38
75
  } | {
39
76
  type: "lap.completed";
40
77
  racerId: string;
@@ -90,6 +127,7 @@ export declare class RaceState {
90
127
  progressOf(racerId: string): RacerProgress | null;
91
128
  private snapshot;
92
129
  standings(): readonly RacerProgress[];
130
+ private expectedCheckpoint;
93
131
  resetToCheckpoint(racerId: string): {
94
132
  position: [number, number, number];
95
133
  heading: number;