@inventure71/paddockjs 3.0.0 → 4.1.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 (97) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/INSTALL_AND_UPDATE.md +21 -4
  3. package/README.md +103 -551
  4. package/docs/architecture.md +28 -18
  5. package/docs/component_inventory.md +5 -4
  6. package/docs/composable-layouts.md +79 -0
  7. package/docs/custom_model_controller.md +4 -4
  8. package/docs/data-helpers.md +50 -0
  9. package/docs/data_contract.md +13 -7
  10. package/docs/getting-started.md +105 -0
  11. package/docs/headless-environment.md +68 -0
  12. package/docs/index.md +17 -4
  13. package/docs/rules.md +6 -2
  14. package/docs/system_specs.md +60 -7
  15. package/docs/theming.md +64 -0
  16. package/docs/training.md +20 -14
  17. package/docs/troubleshooting.md +76 -0
  18. package/docs/upgrading-hosts-to-3.md +42 -0
  19. package/examples/python/README.md +6 -0
  20. package/examples/python/base_policy_server.py +19 -0
  21. package/package.json +21 -5
  22. package/src/api/PaddockSimulatorController.js +78 -22
  23. package/src/api/runtimeTheme.js +59 -0
  24. package/src/app/F1SimulatorApp.js +24 -3
  25. package/src/app/readouts/timingTowerRenderer.js +203 -42
  26. package/src/app/runtime/frameLoop.js +5 -1
  27. package/src/config/defaultOptions.js +8 -1
  28. package/src/config/themeOptions.js +5 -0
  29. package/src/data/index.d.ts +212 -0
  30. package/src/data/index.js +14 -0
  31. package/src/environment/episode.js +11 -1
  32. package/src/environment/metrics.js +59 -16
  33. package/src/environment/observationVector.js +50 -33
  34. package/src/environment/observations.js +89 -20
  35. package/src/environment/runtime.js +41 -0
  36. package/src/environment/sensors/carRays.js +48 -37
  37. package/src/environment/sensors/index.js +501 -97
  38. package/src/environment/sensors/indexedRayBands.js +134 -46
  39. package/src/environment/sensors/rayBandTrace.js +1171 -0
  40. package/src/environment/sensors/rayChannels.js +108 -0
  41. package/src/environment/sensors/rayConfig.js +71 -25
  42. package/src/environment/sensors/rayGeometry.js +23 -17
  43. package/src/environment/sensors/sensorTargets.js +67 -1
  44. package/src/environment/sensors/surfaceRays.js +130 -89
  45. package/src/environment/sensors/trackRays.js +115 -1
  46. package/src/index.d.ts +43 -0
  47. package/src/index.js +27 -2
  48. package/src/placeholder/index.d.ts +11 -0
  49. package/src/placeholder/index.js +61 -0
  50. package/src/placeholder.css +72 -0
  51. package/src/simulation/collisionGeometry.js +260 -96
  52. package/src/simulation/pit/pitFlow.js +21 -9
  53. package/src/simulation/pit/pitQueue.js +5 -3
  54. package/src/simulation/pit/pitRouteMovement.js +9 -4
  55. package/src/simulation/pit/pitRouting.js +77 -42
  56. package/src/simulation/pit/pitState.js +1 -0
  57. package/src/simulation/pit/pitTireService.js +7 -3
  58. package/src/simulation/race/raceFinish.js +25 -8
  59. package/src/simulation/race/raceOrder.js +189 -32
  60. package/src/simulation/race/raceProgress.js +129 -37
  61. package/src/simulation/race/raceSetup.js +2 -0
  62. package/src/simulation/race/raceStep.js +25 -13
  63. package/src/simulation/raceSimulation.js +20 -17
  64. package/src/simulation/rules/collisionSteward.js +40 -23
  65. package/src/simulation/rules/rulesReview.js +77 -56
  66. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  67. package/src/simulation/timing/drsTiming.js +143 -30
  68. package/src/simulation/timing/gapEstimation.js +25 -3
  69. package/src/simulation/timing/lapTelemetry.js +115 -36
  70. package/src/simulation/timing/raceTiming.js +5 -1
  71. package/src/simulation/timing/sectorPerformance.js +104 -26
  72. package/src/simulation/timing/timingHistory.js +229 -14
  73. package/src/simulation/timing/timingLines.js +65 -7
  74. package/src/simulation/track/pitLaneLayout.js +23 -0
  75. package/src/simulation/track/pitLaneState.js +218 -19
  76. package/src/simulation/track/spatialQueries.js +67 -11
  77. package/src/simulation/track/trackMath.js +91 -24
  78. package/src/simulation/track/trackModel.js +2 -0
  79. package/src/simulation/track/trackQueryIndex.js +1334 -100
  80. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  81. package/src/simulation/track/trackQueryScratch.js +7 -0
  82. package/src/simulation/track/trackQueryStats.js +25 -0
  83. package/src/simulation/track/trackSnapshotJson.js +139 -0
  84. package/src/simulation/track/trackStatePolicy.js +249 -1
  85. package/src/simulation/vehicle/contactResolution.js +96 -59
  86. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  87. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  88. package/src/simulation/vehicle/runoffResponse.js +54 -29
  89. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  90. package/src/simulation/vehicle/vehiclePhysics.js +42 -27
  91. package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
  92. package/src/simulation/vehicle/vehicleState.js +6 -0
  93. package/src/simulation/vehicle/wheelSurface.js +807 -92
  94. package/src/styles.css +497 -497
  95. package/src/ui/raceControlsTemplate.js +1 -0
  96. package/src/ui/telemetryDrawerTemplate.js +1 -0
  97. package/src/ui/telemetryTemplates.js +1 -0
@@ -12,7 +12,7 @@ function getSectorLength(track) {
12
12
  return track.length / TELEMETRY_SECTOR_COUNT;
13
13
  }
14
14
 
15
- function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
15
+ function writeLapTelemetryPosition(target, track, raceDistance, totalLaps = Infinity) {
16
16
  const positiveDistance = Math.max(0, raceDistance ?? 0);
17
17
  const lapIndex = Math.min(
18
18
  Math.floor(positiveDistance / track.length),
@@ -25,42 +25,40 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
25
25
  const sectorIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
26
26
  const sectorStart = sectorIndex * sectorLength;
27
27
 
28
- return {
29
- completedLaps: Math.floor(positiveDistance / track.length),
30
- currentLap: Math.max(1, lapIndex + 1),
31
- currentSector: sectorIndex + 1,
32
- currentSectorProgress: clamp((lapProgress - sectorStart) / sectorLength, 0, 1),
33
- };
28
+ target.completedLaps = Math.floor(positiveDistance / track.length);
29
+ target.currentLap = Math.max(1, lapIndex + 1);
30
+ target.currentSector = sectorIndex + 1;
31
+ target.currentSectorProgress = clamp((lapProgress - sectorStart) / sectorLength, 0, 1);
32
+ return target;
34
33
  }
35
34
 
36
35
  function createSectorProgress(position, currentSectors = null, { preserveCompletedSectors = false } = {}) {
37
36
  const activeIndex = clamp((position.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
38
- return createEmptySectorTimes().map((_, index) => {
39
- if (preserveCompletedSectors && Number.isFinite(currentSectors?.[index])) return 1;
40
- if (index < activeIndex && Number.isFinite(currentSectors?.[index])) return 1;
41
- if (index === activeIndex) return clamp(position.currentSectorProgress ?? 0, 0, 1);
42
- return 0;
43
- });
37
+ const progress = createEmptySectorTimes();
38
+ writeSectorProgress(progress, position, currentSectors, { preserveCompletedSectors, activeIndex });
39
+ return progress;
44
40
  }
45
41
 
46
42
  export function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
47
- const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
48
- return {
49
- ...position,
43
+ const telemetry = {
50
44
  currentLapStartedAt: currentTime,
51
45
  currentSectorStartedAt: currentTime,
52
46
  lastUpdatedAt: currentTime,
53
47
  currentLapTime: 0,
54
48
  currentSectorElapsed: 0,
55
49
  currentSectors: createEmptySectorTimes(),
56
- sectorProgress: createSectorProgress(position),
50
+ sectorProgress: createEmptySectorTimes(),
57
51
  liveSectors: createEmptySectorTimes(),
58
52
  lastLapTime: null,
59
53
  bestLapTime: null,
60
54
  lastSectors: createEmptySectorTimes(),
61
55
  bestSectors: createEmptySectorTimes(),
62
56
  sectorPerformance: createEmptySectorPerformance(),
57
+ sectorPerformanceRevision: 0,
63
58
  };
59
+ writeLapTelemetryPosition(telemetry, track, raceDistance, totalLaps);
60
+ telemetry.sectorProgress = createSectorProgress(telemetry);
61
+ return telemetry;
64
62
  }
65
63
 
66
64
  export function resetLapTelemetry(car, currentTime, track, totalLaps) {
@@ -106,7 +104,7 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
106
104
  Math.abs(travelled) > track.length / 2
107
105
  ) {
108
106
  resetLapTelemetry(car, currentTime, track, totalLaps);
109
- return;
107
+ return true;
110
108
  }
111
109
 
112
110
  if (travelled < -1e-3) {
@@ -114,10 +112,11 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
114
112
  preserveCompletedSectors: true,
115
113
  });
116
114
  telemetry.lastUpdatedAt = currentTime;
117
- return;
115
+ return false;
118
116
  }
119
117
 
120
118
  const sectorLength = getSectorLength(track);
119
+ let crossedBoundary = false;
121
120
  if (travelled > 1e-6) {
122
121
  const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
123
122
  const lastBoundary = Math.floor(currentDistance / sectorLength);
@@ -125,6 +124,7 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
125
124
  for (let boundary = firstBoundary; boundary <= lastBoundary; boundary += 1) {
126
125
  const boundaryDistance = boundary * sectorLength;
127
126
  if (boundaryDistance <= previousDistance + 1e-3 || boundaryDistance > currentDistance + 1e-3) continue;
127
+ crossedBoundary = true;
128
128
 
129
129
  const boundaryRatio = clamp((boundaryDistance - previousDistance) / travelled, 0, 1);
130
130
  const crossingTime = previousUpdateTime + boundaryRatio * elapsedTime;
@@ -140,8 +140,8 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
140
140
  telemetry.bestLapTime = Number.isFinite(telemetry.bestLapTime)
141
141
  ? Math.min(telemetry.bestLapTime, lapTime)
142
142
  : lapTime;
143
- telemetry.lastSectors = serializeSectorTimes(telemetry.currentSectors);
144
- telemetry.currentSectors = createEmptySectorTimes();
143
+ telemetry.lastSectors = copySectorTimes(telemetry.lastSectors, telemetry.currentSectors);
144
+ clearSectorTimes(telemetry.currentSectors);
145
145
  telemetry.currentLapStartedAt = crossingTime;
146
146
  telemetry.completedLaps = Math.max(telemetry.completedLaps + 1, Math.min(Math.floor(boundaryDistance / track.length), totalLaps));
147
147
  }
@@ -150,8 +150,18 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
150
150
  }
151
151
  }
152
152
 
153
+ if (!crossedBoundary) {
154
+ syncInProgressLapTelemetry(telemetry, currentTime, currentRaceDistance, track, totalLaps);
155
+ telemetry.lastUpdatedAt = currentTime;
156
+ return false;
157
+ }
158
+
153
159
  syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps);
154
160
  telemetry.lastUpdatedAt = currentTime;
161
+ if (crossedBoundary) {
162
+ telemetry.sectorPerformanceRevision = (telemetry.sectorPerformanceRevision ?? 0) + 1;
163
+ }
164
+ return crossedBoundary;
155
165
  }
156
166
 
157
167
  function updateBestSector(telemetry, sectorIndex, sectorTime) {
@@ -170,32 +180,101 @@ function clearFutureSectorTelemetry(telemetry) {
170
180
 
171
181
  function syncLiveSectorTelemetry(telemetry, { preserveCompletedSectors = false } = {}) {
172
182
  const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
183
+ const liveSectors = ensureSectorTimesArray(telemetry.liveSectors);
184
+ clearSectorTimes(liveSectors);
185
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
186
+ const time = telemetry.currentSectors[index];
187
+ if (!preserveCompletedSectors && index >= activeIndex) continue;
188
+ if (!Number.isFinite(time)) continue;
189
+ liveSectors[index] = time;
190
+ }
191
+ if (!Number.isFinite(liveSectors[activeIndex])) {
192
+ liveSectors[activeIndex] = telemetry.currentSectorElapsed;
193
+ }
194
+ telemetry.liveSectors = liveSectors;
195
+ }
173
196
 
174
- telemetry.liveSectors = createEmptySectorTimes();
175
- telemetry.currentSectors.forEach((time, index) => {
176
- if (!preserveCompletedSectors && index >= activeIndex) return;
177
- if (!Number.isFinite(time)) return;
178
- telemetry.liveSectors[index] = time;
179
- });
180
- if (!Number.isFinite(telemetry.liveSectors[activeIndex])) {
181
- telemetry.liveSectors[activeIndex] = telemetry.currentSectorElapsed;
197
+ function syncInProgressLapTelemetry(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
198
+ const positiveDistance = Math.max(0, currentRaceDistance ?? 0);
199
+ const maxLapIndex = Math.max(0, totalLaps - 1);
200
+ const lapIndex = Math.min(Math.floor(positiveDistance / track.length), maxLapIndex);
201
+ const lapProgress = positiveDistance >= track.length * totalLaps
202
+ ? track.length
203
+ : positiveDistance - lapIndex * track.length;
204
+ const sectorLength = getSectorLength(track);
205
+ const activeIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
206
+ const sectorStart = activeIndex * sectorLength;
207
+ const sectorProgress = ensureSectorTimesArray(telemetry.sectorProgress);
208
+ const liveSectors = ensureSectorTimesArray(telemetry.liveSectors);
209
+
210
+ telemetry.completedLaps = Math.floor(positiveDistance / track.length);
211
+ telemetry.currentLap = Math.max(1, lapIndex + 1);
212
+ telemetry.currentSector = activeIndex + 1;
213
+ telemetry.currentSectorProgress = clamp((lapProgress - sectorStart) / sectorLength, 0, 1);
214
+ telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
215
+ telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
216
+ for (let index = activeIndex; index < TELEMETRY_SECTOR_COUNT; index += 1) {
217
+ telemetry.currentSectors[index] = null;
218
+ liveSectors[index] = null;
219
+ sectorProgress[index] = 0;
182
220
  }
221
+ sectorProgress[activeIndex] = telemetry.currentSectorProgress;
222
+ liveSectors[activeIndex] = telemetry.currentSectorElapsed;
223
+ telemetry.sectorProgress = sectorProgress;
224
+ telemetry.liveSectors = liveSectors;
183
225
  }
184
226
 
185
227
  function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps, {
186
228
  preserveCompletedSectors = false,
187
229
  } = {}) {
188
- const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
189
- telemetry.completedLaps = position.completedLaps;
190
- telemetry.currentLap = position.currentLap;
191
- telemetry.currentSector = position.currentSector;
192
- telemetry.currentSectorProgress = position.currentSectorProgress;
230
+ writeLapTelemetryPosition(telemetry, track, currentRaceDistance, totalLaps);
193
231
  if (!preserveCompletedSectors) clearFutureSectorTelemetry(telemetry);
194
- telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors, {
232
+ const sectorProgress = ensureSectorTimesArray(telemetry.sectorProgress);
233
+ writeSectorProgress(sectorProgress, telemetry, telemetry.currentSectors, {
195
234
  preserveCompletedSectors,
196
235
  });
197
- telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
236
+ telemetry.sectorProgress = sectorProgress;
237
+ telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(telemetry.completedLaps, totalLaps));
198
238
  telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
199
239
  telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
200
240
  syncLiveSectorTelemetry(telemetry, { preserveCompletedSectors });
201
241
  }
242
+
243
+ function ensureSectorTimesArray(values) {
244
+ return Array.isArray(values) && values.length === TELEMETRY_SECTOR_COUNT
245
+ ? values
246
+ : createEmptySectorTimes();
247
+ }
248
+
249
+ function clearSectorTimes(values) {
250
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
251
+ values[index] = null;
252
+ }
253
+ }
254
+
255
+ function copySectorTimes(target, source) {
256
+ const nextTarget = ensureSectorTimesArray(target);
257
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
258
+ nextTarget[index] = finiteOrNull(source?.[index]);
259
+ }
260
+ return nextTarget;
261
+ }
262
+
263
+ function writeSectorProgress(target, position, currentSectors = null, { preserveCompletedSectors = false, activeIndex = null } = {}) {
264
+ const resolvedActiveIndex = activeIndex == null
265
+ ? clamp((position.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1)
266
+ : activeIndex;
267
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
268
+ if (preserveCompletedSectors && Number.isFinite(currentSectors?.[index])) {
269
+ target[index] = 1;
270
+ continue;
271
+ }
272
+ if (index < resolvedActiveIndex && Number.isFinite(currentSectors?.[index])) {
273
+ target[index] = 1;
274
+ continue;
275
+ }
276
+ target[index] = index === resolvedActiveIndex
277
+ ? clamp(position.currentSectorProgress ?? 0, 0, 1)
278
+ : 0;
279
+ }
280
+ }
@@ -5,7 +5,11 @@ export {
5
5
  updateLapTelemetry,
6
6
  } from './lapTelemetry.js';
7
7
  export { estimateGapAheadSeconds, estimateTimingLineGapSeconds, wholeLapGap } from './gapEstimation.js';
8
- export { createEmptySectorPerformance, createEmptySectorTimes, updateSectorPerformance } from './sectorPerformance.js';
8
+ export {
9
+ createEmptySectorPerformance,
10
+ createEmptySectorTimes,
11
+ updateSectorPerformance,
12
+ } from './sectorPerformance.js';
9
13
  export { recordTimingSample, resetTimingHistory, trimTimingHistory } from './timingHistory.js';
10
14
  export {
11
15
  createTimingLines,
@@ -1,5 +1,9 @@
1
1
  import { TELEMETRY_SECTOR_COUNT } from './timingConstants.js';
2
2
 
3
+ const OVERALL_BEST_0 = '_sectorPerformanceOverallBest0';
4
+ const OVERALL_BEST_1 = '_sectorPerformanceOverallBest1';
5
+ const OVERALL_BEST_2 = '_sectorPerformanceOverallBest2';
6
+
3
7
  export function createEmptySectorTimes() {
4
8
  return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
5
9
  }
@@ -20,32 +24,80 @@ export function serializeSectorPerformance(values) {
20
24
  return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
21
25
  }
22
26
 
23
- export function updateSectorPerformance(cars) {
24
- const overallBestSectors = createEmptySectorTimes();
25
- cars.forEach((car) => {
26
- car.lapTelemetry?.bestSectors?.forEach((value, index) => {
27
- if (!Number.isFinite(value)) return;
28
- const previousBest = overallBestSectors[index];
29
- if (!Number.isFinite(previousBest) || value < previousBest) overallBestSectors[index] = value;
30
- });
31
- });
32
- cars.forEach((car) => {
33
- const telemetry = car.lapTelemetry;
34
- if (!telemetry) return;
35
- telemetry.sectorPerformance = {
36
- current: telemetry.currentSectors.map((value, index) => classifySectorPerformance(
37
- value,
38
- telemetry.bestSectors[index],
39
- overallBestSectors[index],
40
- )),
41
- last: telemetry.lastSectors.map((value, index) => classifySectorPerformance(
42
- value,
43
- telemetry.bestSectors[index],
44
- overallBestSectors[index],
45
- )),
46
- best: telemetry.bestSectors.map((value, index) => classifySectorPerformance(value, value, overallBestSectors[index])),
47
- };
48
- });
27
+ export function updateSectorPerformance(cars, stats = null) {
28
+ let overallBest0 = null;
29
+ let overallBest1 = null;
30
+ let overallBest2 = null;
31
+ let changedCars = 0;
32
+
33
+ for (let index = 0; index < cars.length; index += 1) {
34
+ const telemetry = cars[index]?.lapTelemetry;
35
+ if (!telemetry) continue;
36
+ const best0 = finiteOrNull(telemetry.bestSectors?.[0]);
37
+ const best1 = finiteOrNull(telemetry.bestSectors?.[1]);
38
+ const best2 = finiteOrNull(telemetry.bestSectors?.[2]);
39
+ const revision = telemetry.sectorPerformanceRevision ?? 0;
40
+ if (telemetry._sectorPerfAppliedRevision !== revision) changedCars += 1;
41
+ if (Number.isFinite(best0) && (!Number.isFinite(overallBest0) || best0 < overallBest0)) overallBest0 = best0;
42
+ if (Number.isFinite(best1) && (!Number.isFinite(overallBest1) || best1 < overallBest1)) overallBest1 = best1;
43
+ if (Number.isFinite(best2) && (!Number.isFinite(overallBest2) || best2 < overallBest2)) overallBest2 = best2;
44
+ }
45
+
46
+ const overallBestChanged =
47
+ overallBest0 !== cars[OVERALL_BEST_0] ||
48
+ overallBest1 !== cars[OVERALL_BEST_1] ||
49
+ overallBest2 !== cars[OVERALL_BEST_2];
50
+
51
+ if (stats) {
52
+ stats.sectorPerformanceChangedCars = (stats.sectorPerformanceChangedCars ?? 0) + changedCars;
53
+ }
54
+
55
+ if (!overallBestChanged && changedCars === 0) {
56
+ if (stats) stats.sectorPerformanceSkippedRebuilds = (stats.sectorPerformanceSkippedRebuilds ?? 0) + 1;
57
+ return false;
58
+ }
59
+
60
+ cars[OVERALL_BEST_0] = overallBest0;
61
+ cars[OVERALL_BEST_1] = overallBest1;
62
+ cars[OVERALL_BEST_2] = overallBest2;
63
+
64
+ let updatedCars = 0;
65
+ const overallBestSectors = [overallBest0, overallBest1, overallBest2];
66
+ for (let index = 0; index < cars.length; index += 1) {
67
+ const telemetry = cars[index]?.lapTelemetry;
68
+ if (!telemetry) continue;
69
+ const revision = telemetry.sectorPerformanceRevision ?? 0;
70
+ if (!overallBestChanged && telemetry._sectorPerfAppliedRevision === revision) continue;
71
+ const sectorPerformance = ensureSectorPerformanceShape(telemetry.sectorPerformance);
72
+ writeSectorPerformance(
73
+ sectorPerformance.current,
74
+ telemetry.currentSectors,
75
+ telemetry.bestSectors,
76
+ overallBestSectors,
77
+ );
78
+ writeSectorPerformance(
79
+ sectorPerformance.last,
80
+ telemetry.lastSectors,
81
+ telemetry.bestSectors,
82
+ overallBestSectors,
83
+ );
84
+ writeSectorPerformance(
85
+ sectorPerformance.best,
86
+ telemetry.bestSectors,
87
+ telemetry.bestSectors,
88
+ overallBestSectors,
89
+ );
90
+ telemetry.sectorPerformance = sectorPerformance;
91
+ telemetry._sectorPerfAppliedRevision = revision;
92
+ updatedCars += 1;
93
+ }
94
+
95
+ if (stats) {
96
+ stats.sectorPerformanceRebuilds = (stats.sectorPerformanceRebuilds ?? 0) + 1;
97
+ stats.sectorPerformanceUpdatedCars = (stats.sectorPerformanceUpdatedCars ?? 0) + updatedCars;
98
+ if (overallBestChanged) stats.sectorPerformanceOverallBestChanges = (stats.sectorPerformanceOverallBestChanges ?? 0) + 1;
99
+ }
100
+ return true;
49
101
  }
50
102
 
51
103
  export function finiteOrNull(value) {
@@ -58,3 +110,29 @@ function classifySectorPerformance(value, personalBest, overallBest) {
58
110
  if (Number.isFinite(personalBest) && Math.abs(value - personalBest) <= 1e-6) return 'personal-best';
59
111
  return 'slower';
60
112
  }
113
+
114
+ function ensureSectorPerformanceShape(sectorPerformance) {
115
+ if (!sectorPerformance || typeof sectorPerformance !== 'object') {
116
+ return createEmptySectorPerformance();
117
+ }
118
+ sectorPerformance.current = ensureSectorArray(sectorPerformance.current);
119
+ sectorPerformance.last = ensureSectorArray(sectorPerformance.last);
120
+ sectorPerformance.best = ensureSectorArray(sectorPerformance.best);
121
+ return sectorPerformance;
122
+ }
123
+
124
+ function ensureSectorArray(values) {
125
+ return Array.isArray(values) && values.length === TELEMETRY_SECTOR_COUNT
126
+ ? values
127
+ : createEmptySectorTimes();
128
+ }
129
+
130
+ function writeSectorPerformance(target, sectorValues, bestSectors, overallBestSectors) {
131
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
132
+ target[index] = classifySectorPerformance(
133
+ sectorValues?.[index],
134
+ bestSectors?.[index],
135
+ overallBestSectors[index],
136
+ );
137
+ }
138
+ }
@@ -1,39 +1,218 @@
1
1
  import { TIMING_HISTORY_MAX_SAMPLES, TIMING_HISTORY_WINDOW_SECONDS } from './timingConstants.js';
2
2
 
3
+ const TIMING_HISTORY_START_INDEX = '_startIndex';
4
+ const TIMING_HISTORY_COUNT = '_count';
5
+ const TIMING_HISTORY_INTERPOLATION_OFFSET = '_interpolationOffset';
6
+ const TIMING_HISTORY_INTERPOLATION_LOWER_DISTANCE = '_interpolationLowerDistance';
7
+ const TIMING_HISTORY_INTERPOLATION_UPPER_DISTANCE = '_interpolationUpperDistance';
8
+ const TIMING_HISTORY_INTERPOLATION_LOWER_TIME = '_interpolationLowerTime';
9
+ const TIMING_HISTORY_INTERPOLATION_UPPER_TIME = '_interpolationUpperTime';
10
+ const TIMING_HISTORY_INTERPOLATION_STATS = '_interpolationStats';
11
+
12
+ function isStructuredTimingHistory(history) {
13
+ return Boolean(
14
+ history &&
15
+ history.times instanceof Float64Array &&
16
+ history.distances instanceof Float64Array,
17
+ );
18
+ }
19
+
20
+ function createStructuredTimingHistory(currentTime, raceDistance) {
21
+ const history = {
22
+ times: new Float64Array(TIMING_HISTORY_MAX_SAMPLES),
23
+ distances: new Float64Array(TIMING_HISTORY_MAX_SAMPLES),
24
+ };
25
+ history.times[0] = currentTime;
26
+ history.distances[0] = raceDistance;
27
+ history[TIMING_HISTORY_START_INDEX] = 0;
28
+ history[TIMING_HISTORY_COUNT] = 1;
29
+ return history;
30
+ }
31
+
32
+ function getTimingHistoryStartIndex(history) {
33
+ if (isStructuredTimingHistory(history)) {
34
+ const value = history[TIMING_HISTORY_START_INDEX];
35
+ return Number.isInteger(value) && value >= 0 ? value : 0;
36
+ }
37
+ const value = history?.[TIMING_HISTORY_START_INDEX];
38
+ return Number.isInteger(value) && value >= 0 ? value : 0;
39
+ }
40
+
41
+ function setTimingHistoryStartIndex(history, startIndex) {
42
+ if (isStructuredTimingHistory(history)) {
43
+ history[TIMING_HISTORY_START_INDEX] = startIndex;
44
+ return;
45
+ }
46
+ Object.defineProperty(history, TIMING_HISTORY_START_INDEX, {
47
+ value: startIndex,
48
+ writable: true,
49
+ configurable: true,
50
+ enumerable: false,
51
+ });
52
+ }
53
+
54
+ function clearTimingHistoryStartIndex(history) {
55
+ if (isStructuredTimingHistory(history)) {
56
+ history[TIMING_HISTORY_START_INDEX] = 0;
57
+ return;
58
+ }
59
+ delete history[TIMING_HISTORY_START_INDEX];
60
+ }
61
+
62
+ function activeTimingHistoryLength(history, startIndex = getTimingHistoryStartIndex(history)) {
63
+ if (isStructuredTimingHistory(history)) {
64
+ const count = history[TIMING_HISTORY_COUNT];
65
+ return Number.isInteger(count) && count >= 0 ? count : 0;
66
+ }
67
+ return Math.max(0, history.length - startIndex);
68
+ }
69
+
70
+ function compactTimingHistory(history, startIndex) {
71
+ if (isStructuredTimingHistory(history)) {
72
+ history[TIMING_HISTORY_START_INDEX] = startIndex;
73
+ return;
74
+ }
75
+ if (startIndex <= 0) {
76
+ clearTimingHistoryStartIndex(history);
77
+ return;
78
+ }
79
+ history.splice(0, startIndex);
80
+ clearTimingHistoryStartIndex(history);
81
+ }
82
+
83
+ function trimStructuredTimingHistory(history, currentTime) {
84
+ let startIndex = getTimingHistoryStartIndex(history);
85
+ let count = activeTimingHistoryLength(history, startIndex);
86
+ const cutoff = currentTime - TIMING_HISTORY_WINDOW_SECONDS;
87
+ while (
88
+ count > 2 &&
89
+ history.times[startIndex] < cutoff
90
+ ) {
91
+ startIndex = (startIndex + 1) % TIMING_HISTORY_MAX_SAMPLES;
92
+ count -= 1;
93
+ }
94
+ history[TIMING_HISTORY_START_INDEX] = startIndex;
95
+ history[TIMING_HISTORY_COUNT] = count;
96
+ }
97
+
3
98
  export function trimTimingHistory(history, currentTime) {
99
+ if (isStructuredTimingHistory(history)) {
100
+ trimStructuredTimingHistory(history, currentTime);
101
+ return;
102
+ }
103
+ if (!Array.isArray(history)) return;
4
104
  const cutoff = currentTime - TIMING_HISTORY_WINDOW_SECONDS;
5
- while (history.length > 2 && (history[0].time < cutoff || history.length > TIMING_HISTORY_MAX_SAMPLES)) {
6
- history.shift();
105
+ let startIndex = getTimingHistoryStartIndex(history);
106
+ if (
107
+ startIndex === 0 &&
108
+ history.length <= TIMING_HISTORY_MAX_SAMPLES &&
109
+ history.length <= 2
110
+ ) return;
111
+ if (
112
+ startIndex === 0 &&
113
+ history.length <= TIMING_HISTORY_MAX_SAMPLES &&
114
+ history[0]?.time >= cutoff
115
+ ) return;
116
+ while (
117
+ activeTimingHistoryLength(history, startIndex) > 2 &&
118
+ (
119
+ history[startIndex]?.time < cutoff ||
120
+ activeTimingHistoryLength(history, startIndex) > TIMING_HISTORY_MAX_SAMPLES
121
+ )
122
+ ) {
123
+ startIndex += 1;
7
124
  }
125
+ if (startIndex > 0 && (startIndex >= 64 || startIndex * 2 >= history.length)) {
126
+ compactTimingHistory(history, startIndex);
127
+ return;
128
+ }
129
+ if (startIndex > 0) setTimingHistoryStartIndex(history, startIndex);
130
+ else clearTimingHistoryStartIndex(history);
8
131
  }
9
132
 
10
133
  export function resetTimingHistory(car, currentTime) {
11
- car.timingHistory = [{ time: currentTime, raceDistance: car.raceDistance }];
134
+ car.timingHistory = createStructuredTimingHistory(currentTime, car.raceDistance);
12
135
  }
13
136
 
14
137
  export function recordTimingSample(car, currentTime) {
15
138
  if (!Number.isFinite(car.raceDistance)) return;
16
- if (!Array.isArray(car.timingHistory)) {
139
+ if (!isStructuredTimingHistory(car.timingHistory)) {
17
140
  resetTimingHistory(car, currentTime);
18
- return;
19
141
  }
20
-
21
- const previous = car.timingHistory[car.timingHistory.length - 1];
142
+ const history = car.timingHistory;
143
+ const startIndex = getTimingHistoryStartIndex(history);
144
+ const count = activeTimingHistoryLength(history, startIndex);
145
+ const previousIndex = count > 0
146
+ ? (startIndex + count - 1) % TIMING_HISTORY_MAX_SAMPLES
147
+ : 0;
148
+ const previousTime = count > 0 ? history.times[previousIndex] : null;
149
+ const previousRaceDistance = count > 0 ? history.distances[previousIndex] : null;
22
150
  if (
23
- previous &&
24
- Math.abs(previous.time - currentTime) <= 1e-6 &&
25
- Math.abs(previous.raceDistance - car.raceDistance) <= 1e-3
151
+ count > 0 &&
152
+ Math.abs(previousTime - currentTime) <= 1e-6 &&
153
+ Math.abs(previousRaceDistance - car.raceDistance) <= 1e-3
26
154
  ) {
27
155
  return;
28
156
  }
29
157
 
30
- car.timingHistory.push({ time: currentTime, raceDistance: car.raceDistance });
31
- trimTimingHistory(car.timingHistory, currentTime);
158
+ let nextStartIndex = startIndex;
159
+ let nextCount = count;
160
+ let writeIndex = 0;
161
+ if (nextCount < TIMING_HISTORY_MAX_SAMPLES) {
162
+ writeIndex = (nextStartIndex + nextCount) % TIMING_HISTORY_MAX_SAMPLES;
163
+ nextCount += 1;
164
+ } else {
165
+ writeIndex = nextStartIndex;
166
+ nextStartIndex = (nextStartIndex + 1) % TIMING_HISTORY_MAX_SAMPLES;
167
+ }
168
+ history.times[writeIndex] = currentTime;
169
+ history.distances[writeIndex] = car.raceDistance;
170
+ history[TIMING_HISTORY_START_INDEX] = nextStartIndex;
171
+ history[TIMING_HISTORY_COUNT] = nextCount;
172
+ trimStructuredTimingHistory(history, currentTime);
32
173
  }
33
174
 
34
175
  export function interpolateTimeAtDistance(history, targetDistance) {
35
- if (!Array.isArray(history) || history.length < 2) return null;
36
- for (let index = history.length - 1; index > 0; index -= 1) {
176
+ if (isStructuredTimingHistory(history)) {
177
+ const directCachedTime = interpolateDirectCachedSegment(history, targetDistance);
178
+ if (Number.isFinite(directCachedTime)) return directCachedTime;
179
+ const startIndex = getTimingHistoryStartIndex(history);
180
+ const count = activeTimingHistoryLength(history, startIndex);
181
+ if (count < 2) return null;
182
+ const cachedOffset = history[TIMING_HISTORY_INTERPOLATION_OFFSET];
183
+ if (Number.isInteger(cachedOffset) && cachedOffset >= 0 && cachedOffset < count - 1) {
184
+ const cachedTime = interpolateStructuredTimingHistoryAtOffset(history, startIndex, count, targetDistance, cachedOffset);
185
+ if (Number.isFinite(cachedTime)) return cachedTime;
186
+ const resolvedOffset = history[TIMING_HISTORY_INTERPOLATION_OFFSET];
187
+ if (Number.isInteger(resolvedOffset) && resolvedOffset >= 0 && resolvedOffset < count - 1) {
188
+ const currentPosition = count - 1 - resolvedOffset;
189
+ const currentIndex = (startIndex + currentPosition) % TIMING_HISTORY_MAX_SAMPLES;
190
+ const previousIndex = (startIndex + currentPosition - 1) % TIMING_HISTORY_MAX_SAMPLES;
191
+ const currentDistance = history.distances[currentIndex];
192
+ const previousDistance = history.distances[previousIndex];
193
+ if (targetDistance > currentDistance) {
194
+ for (let offset = resolvedOffset - 1; offset >= 0; offset -= 1) {
195
+ const value = interpolateStructuredTimingHistoryAtOffset(history, startIndex, count, targetDistance, offset);
196
+ if (Number.isFinite(value)) return value;
197
+ }
198
+ } else if (targetDistance < previousDistance) {
199
+ for (let offset = resolvedOffset + 1; offset < count - 1; offset += 1) {
200
+ const value = interpolateStructuredTimingHistoryAtOffset(history, startIndex, count, targetDistance, offset);
201
+ if (Number.isFinite(value)) return value;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ for (let offset = 0; offset < count - 1; offset += 1) {
207
+ const value = interpolateStructuredTimingHistoryAtOffset(history, startIndex, count, targetDistance, offset);
208
+ if (Number.isFinite(value)) return value;
209
+ }
210
+ return null;
211
+ }
212
+ if (!Array.isArray(history)) return null;
213
+ const startIndex = getTimingHistoryStartIndex(history);
214
+ if (activeTimingHistoryLength(history, startIndex) < 2) return null;
215
+ for (let index = history.length - 1; index > startIndex; index -= 1) {
37
216
  const current = history[index];
38
217
  const previous = history[index - 1];
39
218
  if (targetDistance < previous.raceDistance || targetDistance > current.raceDistance) continue;
@@ -44,3 +223,39 @@ export function interpolateTimeAtDistance(history, targetDistance) {
44
223
  }
45
224
  return null;
46
225
  }
226
+
227
+ function interpolateStructuredTimingHistoryAtOffset(history, startIndex, count, targetDistance, offsetFromTail) {
228
+ const currentPosition = count - 1 - offsetFromTail;
229
+ const currentIndex = (startIndex + currentPosition) % TIMING_HISTORY_MAX_SAMPLES;
230
+ const previousIndex = (startIndex + currentPosition - 1) % TIMING_HISTORY_MAX_SAMPLES;
231
+ const currentDistance = history.distances[currentIndex];
232
+ const previousDistance = history.distances[previousIndex];
233
+ if (targetDistance < previousDistance || targetDistance > currentDistance) return null;
234
+ history[TIMING_HISTORY_INTERPOLATION_OFFSET] = offsetFromTail;
235
+ history[TIMING_HISTORY_INTERPOLATION_LOWER_DISTANCE] = previousDistance;
236
+ history[TIMING_HISTORY_INTERPOLATION_UPPER_DISTANCE] = currentDistance;
237
+ history[TIMING_HISTORY_INTERPOLATION_LOWER_TIME] = history.times[previousIndex];
238
+ history[TIMING_HISTORY_INTERPOLATION_UPPER_TIME] = history.times[currentIndex];
239
+ const coveredDistance = currentDistance - previousDistance;
240
+ if (coveredDistance <= 1e-6) return history.times[currentIndex];
241
+ const ratio = (targetDistance - previousDistance) / coveredDistance;
242
+ return history.times[previousIndex] + (history.times[currentIndex] - history.times[previousIndex]) * ratio;
243
+ }
244
+
245
+ function interpolateDirectCachedSegment(history, targetDistance) {
246
+ const lowerDistance = history[TIMING_HISTORY_INTERPOLATION_LOWER_DISTANCE];
247
+ const upperDistance = history[TIMING_HISTORY_INTERPOLATION_UPPER_DISTANCE];
248
+ if (!Number.isFinite(lowerDistance) || !Number.isFinite(upperDistance)) return null;
249
+ if (targetDistance < lowerDistance || targetDistance > upperDistance) return null;
250
+
251
+ const lowerTime = history[TIMING_HISTORY_INTERPOLATION_LOWER_TIME];
252
+ const upperTime = history[TIMING_HISTORY_INTERPOLATION_UPPER_TIME];
253
+ const stats = history[TIMING_HISTORY_INTERPOLATION_STATS];
254
+ if (stats) {
255
+ stats.directSegmentCacheHits = (stats.directSegmentCacheHits ?? 0) + 1;
256
+ }
257
+ const coveredDistance = upperDistance - lowerDistance;
258
+ if (coveredDistance <= 1e-6) return upperTime;
259
+ const ratio = (targetDistance - lowerDistance) / coveredDistance;
260
+ return lowerTime + (upperTime - lowerTime) * ratio;
261
+ }