@jgengine/core 0.8.0 → 0.9.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 (228) hide show
  1. package/CHANGELOG.md +16 -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.js +1 -1
  11. package/dist/ai/threat.d.ts +2 -0
  12. package/dist/ai/threat.js +34 -0
  13. package/dist/cartridge/runtime.d.ts +15 -0
  14. package/dist/cartridge/runtime.js +556 -0
  15. package/dist/cartridge/spec.d.ts +188 -0
  16. package/dist/cartridge/spec.js +19 -0
  17. package/dist/cartridge/validate.d.ts +2 -0
  18. package/dist/cartridge/validate.js +78 -0
  19. package/dist/combat/abilityKit.d.ts +17 -2
  20. package/dist/combat/abilityKit.js +62 -13
  21. package/dist/combat/castRunner.d.ts +40 -0
  22. package/dist/combat/castRunner.js +56 -0
  23. package/dist/combat/comboPoints.d.ts +14 -0
  24. package/dist/combat/comboPoints.js +52 -0
  25. package/dist/combat/defensiveWindow.d.ts +1 -1
  26. package/dist/combat/defensiveWindow.js +5 -3
  27. package/dist/combat/projectiles.d.ts +33 -12
  28. package/dist/combat/projectiles.js +201 -129
  29. package/dist/combat/resistance.d.ts +16 -4
  30. package/dist/combat/resistance.js +43 -7
  31. package/dist/combat/resourcePool.d.ts +20 -0
  32. package/dist/combat/resourcePool.js +55 -0
  33. package/dist/combat/shotOrigin.d.ts +40 -0
  34. package/dist/combat/shotOrigin.js +57 -0
  35. package/dist/combat/telegraph.d.ts +25 -0
  36. package/dist/combat/telegraph.js +47 -0
  37. package/dist/crafting/recipe.js +4 -1
  38. package/dist/devtools/devtools.d.ts +72 -24
  39. package/dist/devtools/devtools.js +449 -101
  40. package/dist/devtools/transformTunables.js +3 -3
  41. package/dist/devtools/tunableSchema.d.ts +140 -0
  42. package/dist/devtools/tunableSchema.js +476 -0
  43. package/dist/faction/factions.d.ts +33 -0
  44. package/dist/faction/factions.js +76 -0
  45. package/dist/faction/reputation.d.ts +32 -0
  46. package/dist/faction/reputation.js +82 -0
  47. package/dist/game/chat.d.ts +2 -0
  48. package/dist/game/chat.js +9 -1
  49. package/dist/game/chatFilter.d.ts +15 -0
  50. package/dist/game/chatFilter.js +55 -0
  51. package/dist/game/controlGate.d.ts +4 -0
  52. package/dist/game/controlGate.js +7 -0
  53. package/dist/game/defineGame.d.ts +2 -0
  54. package/dist/game/events.d.ts +10 -0
  55. package/dist/game/gamePhase.d.ts +20 -0
  56. package/dist/game/gamePhase.js +21 -0
  57. package/dist/game/lootTable.d.ts +10 -1
  58. package/dist/game/lootTable.js +27 -7
  59. package/dist/game/playableGame.d.ts +50 -1
  60. package/dist/game/playableGame.js +1 -1
  61. package/dist/game/quest.d.ts +7 -3
  62. package/dist/game/quest.js +17 -8
  63. package/dist/game/race.d.ts +39 -1
  64. package/dist/game/race.js +96 -11
  65. package/dist/game/recordBook.d.ts +29 -0
  66. package/dist/game/recordBook.js +86 -0
  67. package/dist/game/talents.d.ts +47 -0
  68. package/dist/game/talents.js +140 -0
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.js +1 -0
  71. package/dist/input/axisInput.d.ts +26 -3
  72. package/dist/input/axisInput.js +44 -4
  73. package/dist/input/bindingOverrides.d.ts +25 -0
  74. package/dist/input/bindingOverrides.js +95 -0
  75. package/dist/input/pointerAxis.d.ts +30 -0
  76. package/dist/input/pointerAxis.js +31 -0
  77. package/dist/input/touchScheme.d.ts +6 -0
  78. package/dist/input/touchScheme.js +45 -7
  79. package/dist/meta/changelog.d.ts +1 -1
  80. package/dist/meta/changelog.js +23 -1
  81. package/dist/movement/dash.d.ts +3 -0
  82. package/dist/movement/dash.js +19 -5
  83. package/dist/movement/glideModel.d.ts +44 -0
  84. package/dist/movement/glideModel.js +54 -0
  85. package/dist/movement/grappleSwing.d.ts +34 -0
  86. package/dist/movement/grappleSwing.js +56 -0
  87. package/dist/movement/leaderTrail.d.ts +29 -0
  88. package/dist/movement/leaderTrail.js +67 -0
  89. package/dist/movement/steering.d.ts +27 -0
  90. package/dist/movement/steering.js +40 -0
  91. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  92. package/dist/multiplayer/feedWriteGate.js +12 -0
  93. package/dist/multiplayer/presenceModel.d.ts +14 -2
  94. package/dist/multiplayer/presenceModel.js +15 -3
  95. package/dist/nav/corridors.d.ts +30 -0
  96. package/dist/nav/corridors.js +69 -0
  97. package/dist/nav/navGrid.js +30 -4
  98. package/dist/nav/railGraph.d.ts +66 -0
  99. package/dist/nav/railGraph.js +129 -0
  100. package/dist/nav/timetable.d.ts +41 -0
  101. package/dist/nav/timetable.js +94 -0
  102. package/dist/physics/buoyancy.d.ts +6 -0
  103. package/dist/physics/buoyancy.js +14 -0
  104. package/dist/physics/flowTube.d.ts +32 -0
  105. package/dist/physics/flowTube.js +64 -0
  106. package/dist/physics/forceVolume.d.ts +27 -0
  107. package/dist/physics/forceVolume.js +38 -0
  108. package/dist/physics/kinematicVehicle.d.ts +52 -0
  109. package/dist/physics/kinematicVehicle.js +81 -0
  110. package/dist/physics/physicsWorld.d.ts +13 -6
  111. package/dist/physics/physicsWorld.js +169 -40
  112. package/dist/physics/ragdoll.js +1 -1
  113. package/dist/physics/vehicleBody.js +2 -1
  114. package/dist/random/seedLink.d.ts +5 -0
  115. package/dist/random/seedLink.js +35 -0
  116. package/dist/runtime/cameraDirector.d.ts +6 -1
  117. package/dist/runtime/cameraDirector.js +6 -0
  118. package/dist/runtime/commandRunner.d.ts +2 -2
  119. package/dist/runtime/commandRunner.js +2 -2
  120. package/dist/runtime/gameContext.d.ts +21 -0
  121. package/dist/runtime/gameContext.js +184 -9
  122. package/dist/runtime/gameRuntime.d.ts +5 -1
  123. package/dist/runtime/gameRuntime.js +13 -12
  124. package/dist/runtime/hostPersistence.d.ts +9 -1
  125. package/dist/runtime/hostPersistence.js +23 -8
  126. package/dist/runtime/inputSnapshot.d.ts +5 -0
  127. package/dist/runtime/inputSnapshot.js +5 -0
  128. package/dist/runtime/motionIntents.d.ts +14 -6
  129. package/dist/runtime/motionIntents.js +20 -2
  130. package/dist/runtime/snapshot.d.ts +1 -0
  131. package/dist/runtime/snapshot.js +1 -1
  132. package/dist/runtime/visibility.d.ts +74 -0
  133. package/dist/runtime/visibility.js +127 -0
  134. package/dist/scene/colliders.d.ts +44 -0
  135. package/dist/scene/colliders.js +77 -0
  136. package/dist/scene/entityStore.d.ts +22 -5
  137. package/dist/scene/entityStore.js +59 -12
  138. package/dist/scene/objectQuery.d.ts +13 -2
  139. package/dist/scene/objectQuery.js +34 -2
  140. package/dist/scene/objectStore.d.ts +2 -0
  141. package/dist/scene/objectStore.js +31 -12
  142. package/dist/scene/sceneRaycast.d.ts +80 -0
  143. package/dist/scene/sceneRaycast.js +284 -0
  144. package/dist/scene/spatial.d.ts +11 -16
  145. package/dist/scene/spatial.js +99 -38
  146. package/dist/sensor/recordingBuffer.d.ts +6 -0
  147. package/dist/sensor/recordingBuffer.js +7 -0
  148. package/dist/sensor/replayLoop.d.ts +39 -0
  149. package/dist/sensor/replayLoop.js +64 -0
  150. package/dist/sensor/visionCone.d.ts +31 -0
  151. package/dist/sensor/visionCone.js +72 -0
  152. package/dist/session/extraction.d.ts +2 -2
  153. package/dist/session/extraction.js +13 -2
  154. package/dist/settings/settingsModel.d.ts +97 -0
  155. package/dist/settings/settingsModel.js +100 -0
  156. package/dist/stats/statModifiers.d.ts +5 -2
  157. package/dist/stats/statModifiers.js +32 -14
  158. package/dist/store/observableKeyedStore.js +8 -2
  159. package/dist/survival/decayMeter.d.ts +1 -1
  160. package/dist/survival/decayMeter.js +0 -1
  161. package/dist/time/beatClock.d.ts +18 -0
  162. package/dist/time/beatClock.js +24 -0
  163. package/dist/time/idleProgress.d.ts +29 -0
  164. package/dist/time/idleProgress.js +31 -0
  165. package/dist/time/serverTick.d.ts +7 -1
  166. package/dist/time/serverTick.js +27 -4
  167. package/dist/time/stateSchedule.d.ts +57 -0
  168. package/dist/time/stateSchedule.js +121 -0
  169. package/dist/turn/commit.d.ts +2 -2
  170. package/dist/turn/commit.js +2 -2
  171. package/dist/ui/hudLayout.d.ts +58 -0
  172. package/dist/ui/hudLayout.js +207 -0
  173. package/dist/ui/hudScale.d.ts +44 -0
  174. package/dist/ui/hudScale.js +55 -0
  175. package/dist/visibility/assetStreaming.d.ts +66 -0
  176. package/dist/visibility/assetStreaming.js +201 -0
  177. package/dist/visibility/bounds.d.ts +66 -0
  178. package/dist/visibility/bounds.js +113 -0
  179. package/dist/visibility/camera.d.ts +21 -0
  180. package/dist/visibility/camera.js +3 -0
  181. package/dist/visibility/config.d.ts +21 -0
  182. package/dist/visibility/config.js +1 -0
  183. package/dist/visibility/diagnostics.d.ts +26 -0
  184. package/dist/visibility/diagnostics.js +36 -0
  185. package/dist/visibility/distance.d.ts +11 -0
  186. package/dist/visibility/distance.js +30 -0
  187. package/dist/visibility/frustum.d.ts +47 -0
  188. package/dist/visibility/frustum.js +186 -0
  189. package/dist/visibility/occlusion.d.ts +36 -0
  190. package/dist/visibility/occlusion.js +45 -0
  191. package/dist/visibility/settings.d.ts +75 -0
  192. package/dist/visibility/settings.js +49 -0
  193. package/dist/visibility/simulationCulling.d.ts +34 -0
  194. package/dist/visibility/simulationCulling.js +36 -0
  195. package/dist/visibility/spatialIndex.d.ts +45 -0
  196. package/dist/visibility/spatialIndex.js +198 -0
  197. package/dist/visibility/visibilitySystem.d.ts +90 -0
  198. package/dist/visibility/visibilitySystem.js +257 -0
  199. package/dist/world/buildings.d.ts +6 -0
  200. package/dist/world/buildings.js +152 -0
  201. package/dist/world/carve.d.ts +7 -2
  202. package/dist/world/carve.js +17 -7
  203. package/dist/world/cellStates.d.ts +45 -0
  204. package/dist/world/cellStates.js +103 -0
  205. package/dist/world/environmentSummary.d.ts +10 -2
  206. package/dist/world/environmentSummary.js +11 -2
  207. package/dist/world/features.d.ts +56 -8
  208. package/dist/world/features.js +24 -3
  209. package/dist/world/gridInstances.d.ts +7 -8
  210. package/dist/world/gridInstances.js +145 -7
  211. package/dist/world/lod.d.ts +17 -0
  212. package/dist/world/lod.js +66 -0
  213. package/dist/world/mapLayers.d.ts +60 -0
  214. package/dist/world/mapLayers.js +44 -0
  215. package/dist/world/massing.d.ts +45 -0
  216. package/dist/world/massing.js +581 -0
  217. package/dist/world/minimap.d.ts +13 -1
  218. package/dist/world/minimap.js +21 -2
  219. package/dist/world/terrain.d.ts +42 -3
  220. package/dist/world/terrain.js +197 -16
  221. package/dist/world/water.d.ts +7 -0
  222. package/dist/world/water.js +19 -4
  223. package/dist/world/weather.d.ts +3 -18
  224. package/dist/world/weather.js +4 -17
  225. package/dist/world/windZones.d.ts +47 -0
  226. package/dist/world/windZones.js +59 -0
  227. package/llms.txt +2585 -1704
  228. package/package.json +12 -2
@@ -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;
package/dist/game/race.js CHANGED
@@ -1,10 +1,24 @@
1
1
  /**
2
2
  * A race track is an ordered ring of checkpoint trigger volumes plus a lap count. The final checkpoint
3
3
  * is the lap/finish line: a racer completes a lap by passing all checkpoints in order and hitting the
4
- * last one.
4
+ * last one. `forks` splice alternate route segments between mainline checkpoints.
5
5
  */
6
6
  export function raceTrack(config) {
7
- return { checkpoints: config.checkpoints, laps: Math.max(1, Math.floor(config.laps ?? 1)) };
7
+ const checkpoints = config.checkpoints;
8
+ const forks = config.forks ?? [];
9
+ for (const fork of forks) {
10
+ if (fork.afterIndex < 0 || fork.afterIndex >= checkpoints.length - 1) {
11
+ throw new Error(`race fork "${fork.id}" afterIndex ${fork.afterIndex} must sit before the finish line`);
12
+ }
13
+ if (fork.routes.length < 2)
14
+ throw new Error(`race fork "${fork.id}" needs at least two routes`);
15
+ for (const route of fork.routes) {
16
+ if (route.checkpoints.length === 0) {
17
+ throw new Error(`race fork "${fork.id}" route "${route.id}" needs at least one checkpoint`);
18
+ }
19
+ }
20
+ }
21
+ return { checkpoints, laps: Math.max(1, Math.floor(config.laps ?? 1)), forks };
8
22
  }
9
23
  function rankIds(standings) {
10
24
  return standings.map((s) => s.racerId);
@@ -78,6 +92,10 @@ export class RaceState {
78
92
  eliminated: false,
79
93
  startTime,
80
94
  splits: [],
95
+ fork: null,
96
+ routesTaken: {},
97
+ forksDone: {},
98
+ lastHit: null,
81
99
  });
82
100
  this.order.push(racerId);
83
101
  }
@@ -116,6 +134,7 @@ export class RaceState {
116
134
  return r === null || r === undefined ? null : this.snapshot(r);
117
135
  }
118
136
  snapshot(r) {
137
+ const activeFork = r.fork === null ? null : this.track.forks[r.fork.forkIndex];
119
138
  return {
120
139
  racerId: r.racerId,
121
140
  lap: r.lap,
@@ -127,6 +146,10 @@ export class RaceState {
127
146
  finishTime: r.finishTime,
128
147
  eliminated: r.eliminated,
129
148
  splits: [...r.splits],
149
+ routesTaken: { ...r.routesTaken },
150
+ activeRoute: activeFork === null || r.fork === null
151
+ ? null
152
+ : { forkId: activeFork.id, routeId: activeFork.routes[r.fork.routeIndex].id },
130
153
  };
131
154
  }
132
155
  standings() {
@@ -136,16 +159,24 @@ export class RaceState {
136
159
  .sort((a, b) => this.compare(a, b, null))
137
160
  .map((r) => this.snapshot(r));
138
161
  }
162
+ expectedCheckpoint(r) {
163
+ if (r.fork !== null) {
164
+ const fork = this.track.forks[r.fork.forkIndex];
165
+ const route = fork.routes[r.fork.routeIndex];
166
+ return route.checkpoints[r.fork.nextRouteCheckpoint] ?? this.track.checkpoints[r.nextCheckpoint] ?? null;
167
+ }
168
+ return this.track.checkpoints[r.nextCheckpoint] ?? null;
169
+ }
139
170
  resetToCheckpoint(racerId) {
140
171
  const r = this.racers.get(racerId);
141
172
  if (r === undefined)
142
173
  return null;
143
- const cps = this.track.checkpoints;
144
- const idx = r.lastCheckpoint >= 0 ? r.lastCheckpoint : 0;
145
- const cp = cps[idx];
174
+ const cp = r.lastHit ?? this.track.checkpoints[0];
146
175
  if (cp === undefined)
147
176
  return null;
148
- const next = cps[(idx + 1) % cps.length];
177
+ let next = this.expectedCheckpoint(r) ?? cp;
178
+ if (next === cp)
179
+ next = this.track.checkpoints[1 % this.track.checkpoints.length] ?? cp;
149
180
  const heading = Math.atan2(next.center[0] - cp.center[0], next.center[2] - cp.center[2]);
150
181
  return { position: [cp.center[0], cp.center[1], cp.center[2]], heading };
151
182
  }
@@ -161,10 +192,11 @@ export class RaceState {
161
192
  if (positions !== null) {
162
193
  const pa = positions.get(a.racerId);
163
194
  const pb = positions.get(b.racerId);
164
- const cps = this.track.checkpoints;
165
- if (pa !== undefined && pb !== undefined && cps.length > 0) {
166
- const da = distanceToNext(pa, cps[a.nextCheckpoint]);
167
- const db = distanceToNext(pb, cps[b.nextCheckpoint]);
195
+ const ca = this.expectedCheckpoint(a);
196
+ const cb = this.expectedCheckpoint(b);
197
+ if (pa !== undefined && pb !== undefined && ca !== null && cb !== null) {
198
+ const da = distanceToNext(pa, ca);
199
+ const db = distanceToNext(pb, cb);
168
200
  if (da !== db)
169
201
  return da - db;
170
202
  }
@@ -177,6 +209,8 @@ export class RaceState {
177
209
  const cps = this.track.checkpoints;
178
210
  const count = cps.length;
179
211
  if (count > 0) {
212
+ const forks = this.track.forks;
213
+ const maxSteps = count + forks.reduce((sum, fork) => sum + Math.max(...fork.routes.map((route) => route.checkpoints.length)), 0) + 2;
180
214
  for (const id of this.order) {
181
215
  const r = this.racers.get(id);
182
216
  if (r.finished || r.eliminated)
@@ -185,10 +219,61 @@ export class RaceState {
185
219
  if (pos === undefined)
186
220
  continue;
187
221
  let guard = 0;
188
- while (guard < count && insideAabb(pos, cps[r.nextCheckpoint])) {
222
+ while (guard < maxSteps) {
189
223
  guard += 1;
224
+ if (r.fork !== null) {
225
+ const fork = forks[r.fork.forkIndex];
226
+ const route = fork.routes[r.fork.routeIndex];
227
+ const cp = route.checkpoints[r.fork.nextRouteCheckpoint];
228
+ if (!insideAabb(pos, cp))
229
+ break;
230
+ r.lastHit = cp;
231
+ r.progress += 1 / route.checkpoints.length;
232
+ r.splits.push(now - r.startTime);
233
+ events.push({
234
+ type: "checkpoint.hit",
235
+ racerId: id,
236
+ checkpoint: fork.afterIndex,
237
+ lap: r.lap,
238
+ time: now,
239
+ fork: { forkId: fork.id, routeId: route.id, index: r.fork.nextRouteCheckpoint },
240
+ });
241
+ r.fork.nextRouteCheckpoint += 1;
242
+ if (r.fork.nextRouteCheckpoint >= route.checkpoints.length) {
243
+ r.forksDone[fork.id] = true;
244
+ r.fork = null;
245
+ }
246
+ continue;
247
+ }
248
+ let tookRoute = false;
249
+ for (let forkIndex = 0; forkIndex < forks.length; forkIndex += 1) {
250
+ const fork = forks[forkIndex];
251
+ if (fork.afterIndex !== r.lastCheckpoint || r.nextCheckpoint !== fork.afterIndex + 1)
252
+ continue;
253
+ if (r.forksDone[fork.id] === true)
254
+ continue;
255
+ for (let routeIndex = 0; routeIndex < fork.routes.length; routeIndex += 1) {
256
+ const route = fork.routes[routeIndex];
257
+ if (!insideAabb(pos, route.checkpoints[0]))
258
+ continue;
259
+ r.fork = { forkIndex, routeIndex, nextRouteCheckpoint: 0 };
260
+ r.routesTaken[fork.id] = route.id;
261
+ events.push({ type: "fork.taken", racerId: id, forkId: fork.id, routeId: route.id, time: now });
262
+ tookRoute = true;
263
+ break;
264
+ }
265
+ if (tookRoute)
266
+ break;
267
+ }
268
+ if (tookRoute)
269
+ continue;
270
+ const cp = cps[r.nextCheckpoint];
271
+ if (!insideAabb(pos, cp))
272
+ break;
190
273
  const hit = r.nextCheckpoint;
191
274
  r.lastCheckpoint = hit;
275
+ r.lastHit = cp;
276
+ r.forksDone = {};
192
277
  r.progress += 1;
193
278
  r.splits.push(now - r.startTime);
194
279
  events.push({ type: "checkpoint.hit", racerId: id, checkpoint: hit, lap: r.lap, time: now });
@@ -0,0 +1,29 @@
1
+ export interface RecordStorage {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+ export type RecordDirection = "lower" | "higher";
7
+ export interface RecordBookConfig<K extends string> {
8
+ readonly key: string;
9
+ readonly fields: Readonly<Record<K, RecordDirection>>;
10
+ readonly storage?: RecordStorage | null;
11
+ }
12
+ export interface RecordSubmission<K extends string> {
13
+ readonly improved: readonly K[];
14
+ readonly best: Readonly<Partial<Record<K, number>>>;
15
+ }
16
+ export interface RecordBook<K extends string> {
17
+ best(): Readonly<Partial<Record<K, number>>>;
18
+ bestOf(field: K): number | null;
19
+ submit(run: Readonly<Partial<Record<K, number>>>): RecordSubmission<K>;
20
+ clear(): void;
21
+ }
22
+ /**
23
+ * A personal-best record book: named numeric fields each racing toward "lower"
24
+ * (times) or "higher" (scores, streaks), persisted through a structural
25
+ * key-value storage (pass `localStorage` in a browser, a stub in tests, or
26
+ * `null` for in-memory only). Corrupt or unavailable storage degrades to an
27
+ * empty book — a record write never throws into a game tick.
28
+ */
29
+ export declare function createRecordBook<K extends string>(config: RecordBookConfig<K>): RecordBook<K>;
@@ -0,0 +1,86 @@
1
+ function loadStored(config, storage) {
2
+ if (storage === null)
3
+ return {};
4
+ let raw;
5
+ try {
6
+ raw = storage.getItem(config.key);
7
+ }
8
+ catch {
9
+ return {};
10
+ }
11
+ if (raw === null)
12
+ return {};
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ if (typeof parsed !== "object" || parsed === null)
21
+ return {};
22
+ const best = {};
23
+ for (const field of Object.keys(config.fields)) {
24
+ const value = parsed[field];
25
+ if (typeof value === "number" && Number.isFinite(value))
26
+ best[field] = value;
27
+ }
28
+ return best;
29
+ }
30
+ /**
31
+ * A personal-best record book: named numeric fields each racing toward "lower"
32
+ * (times) or "higher" (scores, streaks), persisted through a structural
33
+ * key-value storage (pass `localStorage` in a browser, a stub in tests, or
34
+ * `null` for in-memory only). Corrupt or unavailable storage degrades to an
35
+ * empty book — a record write never throws into a game tick.
36
+ */
37
+ export function createRecordBook(config) {
38
+ const storage = config.storage ?? null;
39
+ let best = loadStored(config, storage);
40
+ function persist() {
41
+ if (storage === null)
42
+ return;
43
+ try {
44
+ storage.setItem(config.key, JSON.stringify(best));
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ }
50
+ return {
51
+ best() {
52
+ return { ...best };
53
+ },
54
+ bestOf(field) {
55
+ return best[field] ?? null;
56
+ },
57
+ submit(run) {
58
+ const improved = [];
59
+ for (const field of Object.keys(config.fields)) {
60
+ const value = run[field];
61
+ if (value === undefined || !Number.isFinite(value))
62
+ continue;
63
+ const current = best[field];
64
+ const beats = current === undefined || (config.fields[field] === "lower" ? value < current : value > current);
65
+ if (beats) {
66
+ best[field] = value;
67
+ improved.push(field);
68
+ }
69
+ }
70
+ if (improved.length > 0)
71
+ persist();
72
+ return { improved, best: { ...best } };
73
+ },
74
+ clear() {
75
+ best = {};
76
+ if (storage === null)
77
+ return;
78
+ try {
79
+ storage.removeItem(config.key);
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,47 @@
1
+ import type { StatModifierSet } from "../stats/statModifiers.js";
2
+ export type TalentRequirement = string | {
3
+ nodeId: string;
4
+ rank: number;
5
+ };
6
+ export interface TalentNodeDef<TStat extends string = string> {
7
+ id: string;
8
+ branch?: string;
9
+ maxRank: number;
10
+ requires?: readonly TalentRequirement[];
11
+ requiresPointsInBranch?: number;
12
+ modifiersPerRank?: StatModifierSet<TStat>;
13
+ grantsAbilities?: readonly string[];
14
+ }
15
+ export type TalentAllocateReason = "unknown-node" | "max-rank" | "no-points" | "requires" | "branch-points";
16
+ export type TalentAllocateResult = {
17
+ ok: true;
18
+ } | {
19
+ ok: false;
20
+ reason: TalentAllocateReason;
21
+ };
22
+ export interface ResolvedTalents<TStat extends string = string> {
23
+ stats: StatModifierSet<TStat>;
24
+ abilities: readonly string[];
25
+ }
26
+ export interface TalentSnapshot {
27
+ points: number;
28
+ ranks: Record<string, number>;
29
+ }
30
+ export interface TalentTreeConfig<TStat extends string = string> {
31
+ nodes: readonly TalentNodeDef<TStat>[];
32
+ points?: number;
33
+ }
34
+ export interface TalentTree<TStat extends string = string> {
35
+ rank(nodeId: string): number;
36
+ pointsAvailable(): number;
37
+ pointsSpent(): number;
38
+ pointsInBranch(branch: string): number;
39
+ grantPoints(amount: number): void;
40
+ canAllocate(nodeId: string): TalentAllocateResult;
41
+ allocate(nodeId: string): TalentAllocateResult;
42
+ resolved(): ResolvedTalents<TStat>;
43
+ reset(): void;
44
+ snapshot(): TalentSnapshot;
45
+ hydrate(snapshot: TalentSnapshot): void;
46
+ }
47
+ export declare function createTalentTree<TStat extends string = string>(config: TalentTreeConfig<TStat>): TalentTree<TStat>;
@@ -0,0 +1,140 @@
1
+ const DEFAULT_BRANCH = "";
2
+ export function createTalentTree(config) {
3
+ const nodesById = new Map();
4
+ for (const node of config.nodes)
5
+ nodesById.set(node.id, node);
6
+ const ranks = new Map();
7
+ let points = Math.max(0, config.points ?? 0);
8
+ let cache = null;
9
+ function rank(nodeId) {
10
+ return ranks.get(nodeId) ?? 0;
11
+ }
12
+ function branchOf(node) {
13
+ return node.branch ?? DEFAULT_BRANCH;
14
+ }
15
+ function pointsInBranch(branch) {
16
+ let total = 0;
17
+ for (const node of nodesById.values()) {
18
+ if (branchOf(node) === branch)
19
+ total += rank(node.id);
20
+ }
21
+ return total;
22
+ }
23
+ function pointsSpent() {
24
+ let total = 0;
25
+ for (const value of ranks.values())
26
+ total += value;
27
+ return total;
28
+ }
29
+ function requirementUnmet(requirement) {
30
+ const reqNodeId = typeof requirement === "string" ? requirement : requirement.nodeId;
31
+ const reqRank = typeof requirement === "string" ? 1 : requirement.rank;
32
+ return rank(reqNodeId) < reqRank;
33
+ }
34
+ function canAllocate(nodeId) {
35
+ const node = nodesById.get(nodeId);
36
+ if (!node)
37
+ return { ok: false, reason: "unknown-node" };
38
+ const currentRank = rank(nodeId);
39
+ if (currentRank >= node.maxRank)
40
+ return { ok: false, reason: "max-rank" };
41
+ if (points < 1)
42
+ return { ok: false, reason: "no-points" };
43
+ for (const requirement of node.requires ?? []) {
44
+ if (requirementUnmet(requirement))
45
+ return { ok: false, reason: "requires" };
46
+ }
47
+ if (node.requiresPointsInBranch !== undefined) {
48
+ const inBranch = pointsInBranch(branchOf(node)) - currentRank;
49
+ if (inBranch < node.requiresPointsInBranch)
50
+ return { ok: false, reason: "branch-points" };
51
+ }
52
+ return { ok: true };
53
+ }
54
+ function resolve() {
55
+ const stats = {};
56
+ const abilities = [];
57
+ const seenAbilities = new Set();
58
+ for (const node of nodesById.values()) {
59
+ const nodeRank = rank(node.id);
60
+ if (nodeRank <= 0)
61
+ continue;
62
+ if (node.modifiersPerRank) {
63
+ for (const stat of Object.keys(node.modifiersPerRank)) {
64
+ const modifier = node.modifiersPerRank[stat];
65
+ if (!modifier)
66
+ continue;
67
+ const entry = stats[stat] ?? {};
68
+ if (modifier.add !== undefined)
69
+ entry.add = (entry.add ?? 0) + modifier.add * nodeRank;
70
+ if (modifier.multiply !== undefined) {
71
+ entry.multiply = (entry.multiply ?? 1) * Math.pow(modifier.multiply, nodeRank);
72
+ }
73
+ stats[stat] = entry;
74
+ }
75
+ }
76
+ if (node.grantsAbilities) {
77
+ for (const ability of node.grantsAbilities) {
78
+ if (seenAbilities.has(ability))
79
+ continue;
80
+ seenAbilities.add(ability);
81
+ abilities.push(ability);
82
+ }
83
+ }
84
+ }
85
+ return { stats, abilities };
86
+ }
87
+ return {
88
+ rank,
89
+ pointsAvailable() {
90
+ return points;
91
+ },
92
+ pointsSpent,
93
+ pointsInBranch,
94
+ grantPoints(amount) {
95
+ if (amount > 0)
96
+ points += amount;
97
+ },
98
+ canAllocate,
99
+ allocate(nodeId) {
100
+ const result = canAllocate(nodeId);
101
+ if (!result.ok)
102
+ return result;
103
+ points -= 1;
104
+ ranks.set(nodeId, rank(nodeId) + 1);
105
+ cache = null;
106
+ return { ok: true };
107
+ },
108
+ resolved() {
109
+ if (cache === null)
110
+ cache = resolve();
111
+ return cache;
112
+ },
113
+ reset() {
114
+ points += pointsSpent();
115
+ ranks.clear();
116
+ cache = null;
117
+ },
118
+ snapshot() {
119
+ const snapshotRanks = {};
120
+ for (const [nodeId, value] of ranks) {
121
+ if (value > 0)
122
+ snapshotRanks[nodeId] = value;
123
+ }
124
+ return { points, ranks: snapshotRanks };
125
+ },
126
+ hydrate(snapshot) {
127
+ ranks.clear();
128
+ points = snapshot.points;
129
+ for (const [nodeId, value] of Object.entries(snapshot.ranks)) {
130
+ const node = nodesById.get(nodeId);
131
+ if (!node)
132
+ continue;
133
+ const clamped = Math.max(0, Math.min(value, node.maxRank));
134
+ if (clamped > 0)
135
+ ranks.set(nodeId, clamped);
136
+ }
137
+ cache = null;
138
+ },
139
+ };
140
+ }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./runtime/commandRunner.js";
4
4
  export * from "./runtime/gameRuntime.js";
5
5
  export * from "./runtime/adapter.js";
6
6
  export * from "./runtime/transport.js";
7
+ export * from "./runtime/visibility.js";
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./runtime/commandRunner.js";
4
4
  export * from "./runtime/gameRuntime.js";
5
5
  export * from "./runtime/adapter.js";
6
6
  export * from "./runtime/transport.js";
7
+ export * from "./runtime/visibility.js";
@@ -1,3 +1,4 @@
1
+ import { type PointerAxisBinding, type PointerAxisState } from "./pointerAxis.js";
1
2
  export interface AxisInput {
2
3
  throttle: number;
3
4
  brake: number;
@@ -9,6 +10,8 @@ export type AxisName = keyof AxisInput;
9
10
  export interface AxisBinding {
10
11
  positive: readonly string[];
11
12
  negative?: readonly string[];
13
+ /** Pointer-position source for this axis (#293) — takes over from the key lists whenever a pointer is active. */
14
+ pointer?: PointerAxisBinding;
12
15
  }
13
16
  export type AxisBindingMap = Record<AxisName, AxisBinding>;
14
17
  export interface AxisChannelConfig {
@@ -25,8 +28,9 @@ export declare function rampToward(current: number, target: number, rate: number
25
28
  /**
26
29
  * Analog control channel — distinct from the digital action bindings. Throttle/brake/steer/handbrake
27
30
  * are continuous values ramped from held keys (a keyboard feels like a pedal) or driven directly from
28
- * a gamepad axis via `setAnalog`. `sample(dt, isDown)` folds the current held-key state into the
29
- * smoothed value; a `setAnalog` override replaces the digital target for that axis until cleared.
31
+ * a gamepad axis via `setAnalog`. `sample(dt, isDown, pointer?)` folds the current held-key state into
32
+ * the smoothed value; a binding's `pointer` source takes over from its keys while a pointer is active,
33
+ * and a `setAnalog` override replaces both for that axis until cleared.
30
34
  */
31
35
  export declare class AxisChannel {
32
36
  private readonly bindings;
@@ -38,6 +42,25 @@ export declare class AxisChannel {
38
42
  clearAnalog(axis: AxisName): void;
39
43
  reset(): void;
40
44
  get value(): AxisInput;
41
- sample(dt: number, isDown: (code: string) => boolean): AxisInput;
45
+ sample(dt: number, isDown: (code: string) => boolean, pointer?: PointerAxisState | null): AxisInput;
42
46
  }
43
47
  export declare const DRIVE_AXIS_BINDINGS: AxisBindingMap;
48
+ export interface GenericAxisConfig<TAxes extends string> {
49
+ bindings: Record<TAxes, AxisBinding>;
50
+ /** Per-axis value range; unlisted axes default to bipolar `[-1, 1]` (use `{ min: 0, max: 1 }` for pedals). */
51
+ ranges?: Partial<Record<TAxes, AxisRange>>;
52
+ smoothing?: number;
53
+ }
54
+ /**
55
+ * The held-key-ramping analog channel for any axis schema (#282.7) — drones (pitch/roll/strafe),
56
+ * boats, mechs — not just the four car axes `AxisChannel` hardcodes. Same semantics: keys ramp,
57
+ * `setAnalog` overrides, a binding's `pointer` source takes over while a pointer is active.
58
+ */
59
+ export interface GenericAxisChannel<TAxes extends string> {
60
+ sample(dt: number, isDown: (code: string) => boolean, pointer?: PointerAxisState | null): Record<TAxes, number>;
61
+ setAnalog(axis: TAxes, value: number): void;
62
+ clearAnalog(axis: TAxes): void;
63
+ reset(): void;
64
+ readonly value: Record<TAxes, number>;
65
+ }
66
+ export declare function createAxisChannel<TAxes extends string>(config: GenericAxisConfig<TAxes>): GenericAxisChannel<TAxes>;