@inventure71/paddockjs 0.1.0 → 0.2.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.
@@ -19,6 +19,7 @@ const GRID_FIRST_SLOT_DISTANCE = -42;
19
19
  const GRID_LATERAL_OFFSET = 42;
20
20
  const TIMING_HISTORY_WINDOW_SECONDS = 18;
21
21
  const TIMING_HISTORY_MAX_SAMPLES = 720;
22
+ const TELEMETRY_SECTOR_COUNT = 3;
22
23
 
23
24
  export const DEFAULT_RULES = {
24
25
  drsDetectionSeconds: 1,
@@ -57,6 +58,202 @@ function isProgressInZone(track, progress, zone) {
57
58
  : wrapped >= start || wrapped <= end;
58
59
  }
59
60
 
61
+ function createEmptySectorTimes() {
62
+ return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
63
+ }
64
+
65
+ function createEmptySectorPerformance() {
66
+ return {
67
+ current: createEmptySectorTimes(),
68
+ last: createEmptySectorTimes(),
69
+ best: createEmptySectorTimes(),
70
+ };
71
+ }
72
+
73
+ function getSectorLength(track) {
74
+ return track.length / TELEMETRY_SECTOR_COUNT;
75
+ }
76
+
77
+ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
78
+ const positiveDistance = Math.max(0, raceDistance ?? 0);
79
+ const lapIndex = Math.min(
80
+ Math.floor(positiveDistance / track.length),
81
+ Math.max(0, totalLaps - 1),
82
+ );
83
+ const lapProgress = positiveDistance >= track.length * totalLaps
84
+ ? track.length
85
+ : positiveDistance - lapIndex * track.length;
86
+ const sectorLength = getSectorLength(track);
87
+ const sectorIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
88
+ const sectorStart = sectorIndex * sectorLength;
89
+
90
+ return {
91
+ completedLaps: Math.floor(positiveDistance / track.length),
92
+ currentLap: Math.max(1, lapIndex + 1),
93
+ currentSector: sectorIndex + 1,
94
+ currentSectorProgress: clamp((lapProgress - sectorStart) / sectorLength, 0, 1),
95
+ };
96
+ }
97
+
98
+ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
99
+ const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
100
+ return {
101
+ ...position,
102
+ currentLapStartedAt: currentTime,
103
+ currentSectorStartedAt: currentTime,
104
+ lastUpdatedAt: currentTime,
105
+ currentLapTime: 0,
106
+ currentSectorElapsed: 0,
107
+ currentSectors: createEmptySectorTimes(),
108
+ lastLapTime: null,
109
+ bestLapTime: null,
110
+ lastSectors: createEmptySectorTimes(),
111
+ bestSectors: createEmptySectorTimes(),
112
+ sectorPerformance: createEmptySectorPerformance(),
113
+ };
114
+ }
115
+
116
+ function resetLapTelemetry(car, currentTime, track, totalLaps) {
117
+ car.lapTelemetry = createLapTelemetry(track, currentTime, car.raceDistance, totalLaps);
118
+ }
119
+
120
+ function finiteOrNull(value) {
121
+ return Number.isFinite(value) ? value : null;
122
+ }
123
+
124
+ function serializeSectorTimes(values) {
125
+ return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
126
+ }
127
+
128
+ function serializeLapTelemetry(telemetry) {
129
+ return {
130
+ currentLap: telemetry.currentLap,
131
+ currentSector: telemetry.currentSector,
132
+ currentLapTime: finiteOrNull(telemetry.currentLapTime),
133
+ currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
134
+ currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
135
+ currentSectors: serializeSectorTimes(telemetry.currentSectors),
136
+ lastLapTime: finiteOrNull(telemetry.lastLapTime),
137
+ bestLapTime: finiteOrNull(telemetry.bestLapTime),
138
+ lastSectors: serializeSectorTimes(telemetry.lastSectors),
139
+ bestSectors: serializeSectorTimes(telemetry.bestSectors),
140
+ sectorPerformance: {
141
+ current: serializeSectorPerformance(telemetry.sectorPerformance?.current),
142
+ last: serializeSectorPerformance(telemetry.sectorPerformance?.last),
143
+ best: serializeSectorPerformance(telemetry.sectorPerformance?.best),
144
+ },
145
+ completedLaps: telemetry.completedLaps,
146
+ };
147
+ }
148
+
149
+ function serializeSectorPerformance(values) {
150
+ return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
151
+ }
152
+
153
+ function updateBestSector(telemetry, sectorIndex, sectorTime) {
154
+ const previousBest = telemetry.bestSectors[sectorIndex];
155
+ if (!Number.isFinite(previousBest) || sectorTime < previousBest) {
156
+ telemetry.bestSectors[sectorIndex] = sectorTime;
157
+ }
158
+ }
159
+
160
+ function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
161
+ const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
162
+ telemetry.currentLap = position.currentLap;
163
+ telemetry.currentSector = position.currentSector;
164
+ telemetry.currentSectorProgress = position.currentSectorProgress;
165
+ telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
166
+ telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
167
+ telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
168
+ }
169
+
170
+ function classifySectorPerformance(value, personalBest, overallBest) {
171
+ if (!Number.isFinite(value)) return null;
172
+ if (Number.isFinite(overallBest) && Math.abs(value - overallBest) <= 1e-6) return 'overall-best';
173
+ if (Number.isFinite(personalBest) && Math.abs(value - personalBest) <= 1e-6) return 'personal-best';
174
+ return 'slower';
175
+ }
176
+
177
+ function updateSectorPerformance(cars) {
178
+ const overallBestSectors = createEmptySectorTimes();
179
+ cars.forEach((car) => {
180
+ car.lapTelemetry?.bestSectors?.forEach((time, index) => {
181
+ if (!Number.isFinite(time)) return;
182
+ const previousBest = overallBestSectors[index];
183
+ if (!Number.isFinite(previousBest) || time < previousBest) overallBestSectors[index] = time;
184
+ });
185
+ });
186
+
187
+ cars.forEach((car) => {
188
+ if (!car.lapTelemetry) return;
189
+ const telemetry = car.lapTelemetry;
190
+ telemetry.sectorPerformance = {
191
+ current: telemetry.currentSectors.map((time, index) => (
192
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
193
+ )),
194
+ last: telemetry.lastSectors.map((time, index) => (
195
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
196
+ )),
197
+ best: telemetry.bestSectors.map((time, index) => (
198
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
199
+ )),
200
+ };
201
+ });
202
+ }
203
+
204
+ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, totalLaps) {
205
+ if (!car.lapTelemetry) resetLapTelemetry(car, currentTime, track, totalLaps);
206
+
207
+ const telemetry = car.lapTelemetry;
208
+ const currentRaceDistance = car.raceDistance ?? 0;
209
+ const previousDistance = Math.max(0, previousRaceDistance ?? currentRaceDistance);
210
+ const currentDistance = Math.max(0, currentRaceDistance);
211
+ const travelled = currentDistance - previousDistance;
212
+ const previousUpdateTime = Number.isFinite(telemetry.lastUpdatedAt) ? telemetry.lastUpdatedAt : currentTime;
213
+ const elapsedTime = Math.max(0, currentTime - previousUpdateTime);
214
+
215
+ if (!Number.isFinite(previousDistance) || !Number.isFinite(currentDistance) || travelled < -1e-3 || travelled > track.length / 2) {
216
+ resetLapTelemetry(car, currentTime, track, totalLaps);
217
+ return;
218
+ }
219
+
220
+ const sectorLength = getSectorLength(track);
221
+ if (travelled > 1e-6) {
222
+ const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
223
+ const lastBoundary = Math.floor(currentDistance / sectorLength);
224
+
225
+ for (let boundary = firstBoundary; boundary <= lastBoundary; boundary += 1) {
226
+ const boundaryDistance = boundary * sectorLength;
227
+ if (boundaryDistance <= previousDistance + 1e-3 || boundaryDistance > currentDistance + 1e-3) continue;
228
+
229
+ const boundaryRatio = clamp((boundaryDistance - previousDistance) / travelled, 0, 1);
230
+ const crossingTime = previousUpdateTime + boundaryRatio * elapsedTime;
231
+ const sectorIndex = (boundary - 1) % TELEMETRY_SECTOR_COUNT;
232
+ const sectorTime = Math.max(0, crossingTime - telemetry.currentSectorStartedAt);
233
+
234
+ telemetry.currentSectors[sectorIndex] = sectorTime;
235
+ updateBestSector(telemetry, sectorIndex, sectorTime);
236
+
237
+ if (sectorIndex === TELEMETRY_SECTOR_COUNT - 1) {
238
+ const lapTime = Math.max(0, crossingTime - telemetry.currentLapStartedAt);
239
+ telemetry.lastLapTime = lapTime;
240
+ telemetry.bestLapTime = Number.isFinite(telemetry.bestLapTime)
241
+ ? Math.min(telemetry.bestLapTime, lapTime)
242
+ : lapTime;
243
+ telemetry.lastSectors = serializeSectorTimes(telemetry.currentSectors);
244
+ telemetry.currentSectors = createEmptySectorTimes();
245
+ telemetry.currentLapStartedAt = crossingTime;
246
+ telemetry.completedLaps = Math.max(telemetry.completedLaps + 1, Math.min(Math.floor(boundaryDistance / track.length), totalLaps));
247
+ }
248
+
249
+ telemetry.currentSectorStartedAt = crossingTime;
250
+ }
251
+ }
252
+
253
+ syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps);
254
+ telemetry.lastUpdatedAt = currentTime;
255
+ }
256
+
60
257
  function createCar(driver, index, random, track, { standingStart = false } = {}) {
61
258
  const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
62
259
  const start = pointAt(track, gridDistance);
@@ -123,6 +320,7 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
123
320
  drsZoneId: null,
124
321
  drsZoneEnabled: false,
125
322
  timingHistory: [],
323
+ lapTelemetry: createLapTelemetry(track, 0, gridDistance),
126
324
  drsDetection: {},
127
325
  canAttack: true,
128
326
  trackState: start,
@@ -345,6 +543,7 @@ function serializeCar(car, rank) {
345
543
  raceDistance: car.raceDistance,
346
544
  distanceMeters: simUnitsToMeters(car.raceDistance),
347
545
  lap: car.lap,
546
+ lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
348
547
  gapAhead: car.gapAhead,
349
548
  gapAheadMeters: Number.isFinite(car.gapAhead) ? simUnitsToMeters(car.gapAhead) : Infinity,
350
549
  gapAheadSeconds: car.gapAheadSeconds,
@@ -409,6 +608,7 @@ export class F1RaceSimulation {
409
608
  }));
410
609
  this.recalculateRaceState({ updateDrs: false });
411
610
  this.cars.forEach((car) => resetTimingHistory(car, this.time));
611
+ this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
412
612
  }
413
613
 
414
614
  setSafetyCar(deployed) {
@@ -465,6 +665,7 @@ export class F1RaceSimulation {
465
665
  car.drsEligible = false;
466
666
  car.drsDetection = {};
467
667
  resetTimingHistory(car, this.time);
668
+ resetLapTelemetry(car, this.time, this.track, this.totalLaps);
468
669
  this.recalculateRaceState({ updateDrs: false });
469
670
  this.evaluateRaceFinish();
470
671
  }
@@ -687,12 +888,14 @@ export class F1RaceSimulation {
687
888
 
688
889
  recalculateRaceState({ updateDrs = true } = {}) {
689
890
  this.cars.forEach((car) => {
891
+ const previousRaceDistance = car.raceDistance;
690
892
  if (car.gridLocked) {
691
893
  const gridPoint = pointAt(this.track, car.gridDistance);
692
894
  car.trackState = nearestTrackState(this.track, car, car.gridDistance);
693
895
  car.progress = gridPoint.distance;
694
896
  car.raceDistance = car.gridDistance;
695
897
  car.lap = 1;
898
+ resetLapTelemetry(car, this.time, this.track, this.totalLaps);
696
899
  return;
697
900
  }
698
901
 
@@ -702,8 +905,10 @@ export class F1RaceSimulation {
702
905
  car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
703
906
  car.progress = car.trackState.distance;
704
907
  car.lap = this.computeLap(car.raceDistance);
908
+ updateLapTelemetry(car, previousRaceDistance, this.time, this.track, this.totalLaps);
705
909
  });
706
910
 
911
+ updateSectorPerformance(this.cars);
707
912
  this.cars.forEach((car) => recordTimingSample(car, this.time));
708
913
 
709
914
  const ordered = this.orderedCars();
@@ -346,6 +346,26 @@ function normalizeDrsZone(zone, totalLength) {
346
346
  };
347
347
  }
348
348
 
349
+ function createTrackSectors(totalLength) {
350
+ return Array.from({ length: 3 }, (_, index) => {
351
+ const startRatio = index / 3;
352
+ const endRatio = (index + 1) / 3;
353
+ const start = totalLength * startRatio;
354
+ const end = totalLength * endRatio;
355
+
356
+ return {
357
+ index: index + 1,
358
+ id: `s${index + 1}`,
359
+ label: `S${index + 1}`,
360
+ start,
361
+ end,
362
+ startRatio,
363
+ endRatio,
364
+ length: end - start,
365
+ };
366
+ });
367
+ }
368
+
349
369
  function scoreStraightWindow(samples, startIndex, windowSize) {
350
370
  let curvature = 0;
351
371
  for (let offset = 0; offset < windowSize; offset += 1) {
@@ -426,6 +446,7 @@ export function buildTrackModel(track = TRACK) {
426
446
  centerlineControls: controls,
427
447
  length: totalLength,
428
448
  samples: normalizedSamples,
449
+ sectors: createTrackSectors(totalLength),
429
450
  drsZones: (track.drsZones ?? deriveDrsZones(normalizedSamples, totalLength))
430
451
  .map((zone) => normalizeDrsZone(zone, totalLength)),
431
452
  };