@inventure71/paddockjs 0.1.0 → 0.3.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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/INSTALL_AND_UPDATE.md +163 -0
  3. package/README.md +96 -16
  4. package/docs/architecture.md +55 -11
  5. package/docs/concepts.md +5 -1
  6. package/docs/data_contract.md +324 -8
  7. package/docs/data_model.md +1 -0
  8. package/docs/index.md +1 -0
  9. package/docs/rules.md +2 -0
  10. package/docs/system_specs.md +66 -11
  11. package/docs/training.md +104 -0
  12. package/examples/train-basic-policy.mjs +277 -0
  13. package/package.json +14 -7
  14. package/src/api/PaddockSimulatorController.js +64 -3
  15. package/src/app/BrowserExpertAdapter.js +32 -0
  16. package/src/app/F1SimulatorApp.js +308 -38
  17. package/src/app/domBindings.js +35 -11
  18. package/src/config/defaultOptions.js +33 -0
  19. package/src/data/championship.js +13 -0
  20. package/src/data/normalizeDrivers.js +7 -2
  21. package/src/environment/actions.js +45 -0
  22. package/src/environment/episode.js +17 -0
  23. package/src/environment/events.js +17 -0
  24. package/src/environment/index.d.ts +241 -0
  25. package/src/environment/index.js +2 -0
  26. package/src/environment/observations.js +112 -0
  27. package/src/environment/options.js +150 -0
  28. package/src/environment/rewards.js +69 -0
  29. package/src/environment/runtime.js +140 -0
  30. package/src/environment/sensors.js +257 -0
  31. package/src/environment/specs.js +79 -0
  32. package/src/index.d.ts +97 -4
  33. package/src/index.js +12 -2
  34. package/src/rendering/proceduralTrackAsset.js +7 -1
  35. package/src/simulation/raceSimulation.js +214 -1
  36. package/src/simulation/trackModel.js +21 -0
  37. package/src/styles.css +495 -2
  38. package/src/ui/componentTemplates.js +201 -4
  39. package/AGENTS.md +0 -86
  40. package/src/standalone.js +0 -15
package/src/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
2
+
3
+ export type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
4
+
1
5
  export type TireCompound = 'S' | 'M' | 'H';
2
- export type CameraMode = 'overview' | 'leader' | 'selected';
6
+ export type CameraMode = 'overview' | 'leader' | 'selected' | 'show-all';
3
7
  export type RaceBannerMode = 'project' | 'radio' | 'hidden';
4
8
  export type RaceBannerEnabledMode = 'project' | 'radio';
5
9
  export type RaceDataBannerSize = 'auto' | 'custom';
@@ -7,6 +11,8 @@ export type TimingTowerVerticalFit = 'expand-race-view' | 'scroll';
7
11
  export type LayoutPreset = 'standard' | 'left-tower-overlay';
8
12
  export type CameraControlsMode = 'embedded' | 'external' | false;
9
13
  export type PaddockPresetName = 'dashboard' | 'timing-overlay' | 'compact-race' | 'full-dashboard';
14
+ export type TelemetryModuleName = 'core' | 'sectors' | 'lapTimes' | 'sectorTimes';
15
+ export type SectorPerformanceStatus = 'overall-best' | 'personal-best' | 'slower';
10
16
 
11
17
  export interface CustomField {
12
18
  label: string;
@@ -170,12 +176,14 @@ export interface F1SimulatorUiOptions {
170
176
  showTimingTower?: boolean;
171
177
  showTelemetry?: boolean;
172
178
  telemetryIncludesOverview?: boolean;
179
+ telemetryModules?: boolean | TelemetryModuleName[] | Partial<Record<TelemetryModuleName, boolean>>;
173
180
  showRaceDataPanel?: boolean;
174
181
  raceDataBanners?: {
175
182
  initial?: RaceBannerMode;
176
183
  enabled?: true | false | RaceBannerEnabledMode[];
177
184
  };
178
185
  raceDataBannerSize?: RaceDataBannerSize;
186
+ raceDataTelemetryDetail?: boolean;
179
187
  timingTowerVerticalFit?: TimingTowerVerticalFit;
180
188
  }
181
189
 
@@ -197,9 +205,40 @@ export interface TrackSnapshot {
197
205
  seed?: number | null;
198
206
  width?: number;
199
207
  length?: number;
208
+ sectors?: TrackSectorSnapshot[];
200
209
  [key: string]: unknown;
201
210
  }
202
211
 
212
+ export interface TrackSectorSnapshot {
213
+ index: number;
214
+ id: string;
215
+ label: string;
216
+ start: number;
217
+ end: number;
218
+ startRatio: number;
219
+ endRatio: number;
220
+ length: number;
221
+ }
222
+
223
+ export interface LapTelemetrySnapshot {
224
+ currentLap: number;
225
+ currentSector: number;
226
+ currentLapTime: number | null;
227
+ currentSectorElapsed: number | null;
228
+ currentSectorProgress: number | null;
229
+ currentSectors: Array<number | null>;
230
+ sectorPerformance: {
231
+ current: Array<SectorPerformanceStatus | null>;
232
+ last: Array<SectorPerformanceStatus | null>;
233
+ best: Array<SectorPerformanceStatus | null>;
234
+ };
235
+ lastLapTime: number | null;
236
+ bestLapTime: number | null;
237
+ lastSectors: Array<number | null>;
238
+ bestSectors: Array<number | null>;
239
+ completedLaps: number;
240
+ }
241
+
203
242
  export interface RaceEvent {
204
243
  type: string;
205
244
  at?: number;
@@ -221,6 +260,7 @@ export interface CarSnapshot {
221
260
  classifiedRank?: number | null;
222
261
  intervalAheadSeconds?: number | null;
223
262
  leaderGapSeconds?: number | null;
263
+ lapTelemetry?: LapTelemetrySnapshot;
224
264
  [key: string]: unknown;
225
265
  }
226
266
 
@@ -288,6 +328,31 @@ export interface F1SimulatorCallbacks {
288
328
  }) => void;
289
329
  }
290
330
 
331
+ export interface F1SimulatorExpertOptions {
332
+ enabled: boolean;
333
+ controlledDrivers: string[];
334
+ frameSkip?: number;
335
+ visualizeSensors?: boolean | {
336
+ rays?: boolean;
337
+ };
338
+ }
339
+
340
+ export interface F1SimulatorExpertAction {
341
+ steering: number;
342
+ throttle: number;
343
+ brake: number;
344
+ }
345
+
346
+ export interface F1SimulatorExpertApi {
347
+ reset(options?: unknown): unknown;
348
+ step(actions: Record<string, F1SimulatorExpertAction>): unknown;
349
+ getObservation(): unknown;
350
+ getState(): unknown;
351
+ getActionSpec(): PaddockActionSpec;
352
+ getObservationSpec(): PaddockObservationSpec;
353
+ destroy(): void;
354
+ }
355
+
291
356
  export interface F1SimulatorOptions extends F1SimulatorCallbacks {
292
357
  preset?: PaddockPresetName;
293
358
  drivers: SimulatorDriver[];
@@ -304,11 +369,15 @@ export interface F1SimulatorOptions extends F1SimulatorCallbacks {
304
369
  showBackLink?: boolean;
305
370
  ui?: F1SimulatorUiOptions;
306
371
  assets?: F1SimulatorAssets;
372
+ expert?: F1SimulatorExpertOptions;
307
373
  }
308
374
 
375
+ export type F1SimulatorRestartOptions = Partial<Omit<F1SimulatorOptions, 'assets' | 'expert'>>;
376
+
309
377
  export interface MountRaceCanvasOptions {
310
378
  includeRaceDataPanel?: boolean;
311
379
  includeTimingTower?: boolean;
380
+ includeTelemetrySectorBanner?: boolean;
312
381
  timingTowerVerticalFit?: TimingTowerVerticalFit;
313
382
  }
314
383
 
@@ -316,9 +385,16 @@ export interface MountTelemetryPanelOptions {
316
385
  includeOverview?: boolean;
317
386
  }
318
387
 
388
+ export interface MountRaceTelemetryDrawerOptions {
389
+ timingTowerVerticalFit?: TimingTowerVerticalFit;
390
+ drawerInitiallyOpen?: boolean;
391
+ raceDataTelemetryDetail?: boolean;
392
+ }
393
+
319
394
  export interface F1MountedSimulator {
395
+ readonly expert: F1SimulatorExpertApi | null;
320
396
  destroy(): void;
321
- restart(nextOptions?: Partial<F1SimulatorOptions>): void;
397
+ restart(nextOptions?: F1SimulatorRestartOptions): void;
322
398
  selectDriver(driverId: string): void;
323
399
  setSafetyCarDeployed(deployed: boolean): void;
324
400
  callSafetyCar(): void;
@@ -328,19 +404,26 @@ export interface F1MountedSimulator {
328
404
  }
329
405
 
330
406
  export interface PaddockSimulatorController {
407
+ readonly expert: F1SimulatorExpertApi | null;
331
408
  mountRaceControls<T extends Element>(root: T): T;
332
409
  mountCameraControls<T extends Element>(root: T): T;
333
410
  mountSafetyCarControl<T extends Element>(root: T): T;
334
411
  mountTimingTower<T extends Element>(root: T): T;
335
412
  mountRaceCanvas<T extends Element>(root: T, options?: MountRaceCanvasOptions): T;
336
413
  mountTelemetryPanel<T extends Element>(root: T, options?: MountTelemetryPanelOptions): T;
414
+ mountTelemetryCore<T extends Element>(root: T): T;
415
+ mountTelemetrySectors<T extends Element>(root: T): T;
416
+ mountTelemetrySectorBanner<T extends Element>(root: T): T;
417
+ mountTelemetryLapTimes<T extends Element>(root: T): T;
418
+ mountTelemetrySectorTimes<T extends Element>(root: T): T;
419
+ mountRaceTelemetryDrawer<T extends Element>(root: T, options?: MountRaceTelemetryDrawerOptions): T;
337
420
  mountCarDriverOverview<T extends Element>(root: T): T;
338
421
  mountRaceDataPanel<T extends Element>(root: T): T;
339
422
  querySelector(selector: string): Element | null;
340
423
  querySelectorAll(selector: string): Element[];
341
424
  start(): Promise<PaddockSimulatorController>;
342
425
  destroy(): void;
343
- restart(nextOptions?: Partial<F1SimulatorOptions>): void;
426
+ restart(nextOptions?: F1SimulatorRestartOptions): void;
344
427
  selectDriver(driverId: string): void;
345
428
  setSafetyCarDeployed(deployed: boolean): void;
346
429
  callSafetyCar(): void;
@@ -369,7 +452,7 @@ export function buildChampionshipDriverGrid(
369
452
  export function formatDriverNumber(driverNumber: number | string | null | undefined): string;
370
453
  export function normalizeSimulatorDrivers(
371
454
  drivers: SimulatorDriver[],
372
- options?: { entries?: ChampionshipEntryBlueprint[] },
455
+ options?: { entries?: ChampionshipEntryBlueprint[]; caller?: string },
373
456
  ): NormalizedSimulatorDriver[];
374
457
 
375
458
  export function metersToSimUnits(meters: number): number;
@@ -395,5 +478,15 @@ export function mountTelemetryPanel<T extends Element>(
395
478
  simulator: PaddockSimulatorController,
396
479
  options?: MountTelemetryPanelOptions,
397
480
  ): T;
481
+ export function mountTelemetryCore<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
482
+ export function mountTelemetrySectors<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
483
+ export function mountTelemetrySectorBanner<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
484
+ export function mountTelemetryLapTimes<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
485
+ export function mountTelemetrySectorTimes<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
486
+ export function mountRaceTelemetryDrawer<T extends Element>(
487
+ root: T,
488
+ simulator: PaddockSimulatorController,
489
+ options?: MountRaceTelemetryDrawerOptions,
490
+ ): T;
398
491
  export function mountCarDriverOverview<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
399
492
  export function mountRaceDataPanel<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
package/src/index.js CHANGED
@@ -9,8 +9,14 @@ export {
9
9
  mountRaceCanvas,
10
10
  mountRaceControls,
11
11
  mountRaceDataPanel,
12
+ mountRaceTelemetryDrawer,
12
13
  mountSafetyCarControl,
14
+ mountTelemetryCore,
15
+ mountTelemetryLapTimes,
13
16
  mountTelemetryPanel,
17
+ mountTelemetrySectorBanner,
18
+ mountTelemetrySectorTimes,
19
+ mountTelemetrySectors,
14
20
  mountTimingTower,
15
21
  } from './api/PaddockSimulatorController.js';
16
22
 
@@ -85,13 +91,17 @@ export async function mountF1Simulator(root, options = {}) {
85
91
  await app.init();
86
92
 
87
93
  return {
94
+ get expert() {
95
+ return app.expert ?? null;
96
+ },
88
97
  destroy() {
89
98
  app.destroy();
90
99
  root.innerHTML = '';
91
100
  },
92
101
  restart(nextOptions = {}) {
93
- resolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(resolvedOptions, nextOptions));
94
- app.restart(resolvedOptions);
102
+ const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(resolvedOptions, nextOptions));
103
+ app.restart(nextResolvedOptions);
104
+ resolvedOptions = nextResolvedOptions;
95
105
  },
96
106
  selectDriver(driverId) {
97
107
  app.selectCar(driverId, { focus: true });
@@ -48,6 +48,12 @@ function textureOrWhite(texture) {
48
48
  return texture ?? Texture.WHITE;
49
49
  }
50
50
 
51
+ function destroyDisplayChildren(container) {
52
+ container.removeChildren().forEach((child) => {
53
+ child.destroy?.({ children: true, texture: false, textureSource: false });
54
+ });
55
+ }
56
+
51
57
  function arcDistance(track, first, second) {
52
58
  const delta = Math.abs(first - second);
53
59
  return Math.min(delta, track.length - delta);
@@ -269,7 +275,7 @@ export class ProceduralTrackAsset {
269
275
  }
270
276
 
271
277
  render(track) {
272
- this.container.removeChildren();
278
+ destroyDisplayChildren(this.container);
273
279
  this.addGrass();
274
280
  this.addGravelRunoff(track);
275
281
  this.addBoundaryUnderlay(track);
@@ -13,12 +13,15 @@ import { simSpeedToKph, simUnitsToMeters } from './units.js';
13
13
  import { getCarCorners, integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
14
14
 
15
15
  const DEFAULT_TOTAL_LAPS = 10;
16
+ export const FIXED_STEP = 1 / 60;
17
+ const MIN_TOTAL_LAPS = 1;
16
18
  const MAX_COLLISION_CORRECTION = 4.5;
17
19
  const GRID_SLOT_SPACING = 82;
18
20
  const GRID_FIRST_SLOT_DISTANCE = -42;
19
21
  const GRID_LATERAL_OFFSET = 42;
20
22
  const TIMING_HISTORY_WINDOW_SECONDS = 18;
21
23
  const TIMING_HISTORY_MAX_SAMPLES = 720;
24
+ const TELEMETRY_SECTOR_COUNT = 3;
22
25
 
23
26
  export const DEFAULT_RULES = {
24
27
  drsDetectionSeconds: 1,
@@ -57,6 +60,202 @@ function isProgressInZone(track, progress, zone) {
57
60
  : wrapped >= start || wrapped <= end;
58
61
  }
59
62
 
63
+ function createEmptySectorTimes() {
64
+ return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
65
+ }
66
+
67
+ function createEmptySectorPerformance() {
68
+ return {
69
+ current: createEmptySectorTimes(),
70
+ last: createEmptySectorTimes(),
71
+ best: createEmptySectorTimes(),
72
+ };
73
+ }
74
+
75
+ function getSectorLength(track) {
76
+ return track.length / TELEMETRY_SECTOR_COUNT;
77
+ }
78
+
79
+ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
80
+ const positiveDistance = Math.max(0, raceDistance ?? 0);
81
+ const lapIndex = Math.min(
82
+ Math.floor(positiveDistance / track.length),
83
+ Math.max(0, totalLaps - 1),
84
+ );
85
+ const lapProgress = positiveDistance >= track.length * totalLaps
86
+ ? track.length
87
+ : positiveDistance - lapIndex * track.length;
88
+ const sectorLength = getSectorLength(track);
89
+ const sectorIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
90
+ const sectorStart = sectorIndex * sectorLength;
91
+
92
+ return {
93
+ completedLaps: Math.floor(positiveDistance / track.length),
94
+ currentLap: Math.max(1, lapIndex + 1),
95
+ currentSector: sectorIndex + 1,
96
+ currentSectorProgress: clamp((lapProgress - sectorStart) / sectorLength, 0, 1),
97
+ };
98
+ }
99
+
100
+ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
101
+ const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
102
+ return {
103
+ ...position,
104
+ currentLapStartedAt: currentTime,
105
+ currentSectorStartedAt: currentTime,
106
+ lastUpdatedAt: currentTime,
107
+ currentLapTime: 0,
108
+ currentSectorElapsed: 0,
109
+ currentSectors: createEmptySectorTimes(),
110
+ lastLapTime: null,
111
+ bestLapTime: null,
112
+ lastSectors: createEmptySectorTimes(),
113
+ bestSectors: createEmptySectorTimes(),
114
+ sectorPerformance: createEmptySectorPerformance(),
115
+ };
116
+ }
117
+
118
+ function resetLapTelemetry(car, currentTime, track, totalLaps) {
119
+ car.lapTelemetry = createLapTelemetry(track, currentTime, car.raceDistance, totalLaps);
120
+ }
121
+
122
+ function finiteOrNull(value) {
123
+ return Number.isFinite(value) ? value : null;
124
+ }
125
+
126
+ function serializeSectorTimes(values) {
127
+ return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
128
+ }
129
+
130
+ function serializeLapTelemetry(telemetry) {
131
+ return {
132
+ currentLap: telemetry.currentLap,
133
+ currentSector: telemetry.currentSector,
134
+ currentLapTime: finiteOrNull(telemetry.currentLapTime),
135
+ currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
136
+ currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
137
+ currentSectors: serializeSectorTimes(telemetry.currentSectors),
138
+ lastLapTime: finiteOrNull(telemetry.lastLapTime),
139
+ bestLapTime: finiteOrNull(telemetry.bestLapTime),
140
+ lastSectors: serializeSectorTimes(telemetry.lastSectors),
141
+ bestSectors: serializeSectorTimes(telemetry.bestSectors),
142
+ sectorPerformance: {
143
+ current: serializeSectorPerformance(telemetry.sectorPerformance?.current),
144
+ last: serializeSectorPerformance(telemetry.sectorPerformance?.last),
145
+ best: serializeSectorPerformance(telemetry.sectorPerformance?.best),
146
+ },
147
+ completedLaps: telemetry.completedLaps,
148
+ };
149
+ }
150
+
151
+ function serializeSectorPerformance(values) {
152
+ return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
153
+ }
154
+
155
+ function updateBestSector(telemetry, sectorIndex, sectorTime) {
156
+ const previousBest = telemetry.bestSectors[sectorIndex];
157
+ if (!Number.isFinite(previousBest) || sectorTime < previousBest) {
158
+ telemetry.bestSectors[sectorIndex] = sectorTime;
159
+ }
160
+ }
161
+
162
+ function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
163
+ const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
164
+ telemetry.currentLap = position.currentLap;
165
+ telemetry.currentSector = position.currentSector;
166
+ telemetry.currentSectorProgress = position.currentSectorProgress;
167
+ telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
168
+ telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
169
+ telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
170
+ }
171
+
172
+ function classifySectorPerformance(value, personalBest, overallBest) {
173
+ if (!Number.isFinite(value)) return null;
174
+ if (Number.isFinite(overallBest) && Math.abs(value - overallBest) <= 1e-6) return 'overall-best';
175
+ if (Number.isFinite(personalBest) && Math.abs(value - personalBest) <= 1e-6) return 'personal-best';
176
+ return 'slower';
177
+ }
178
+
179
+ function updateSectorPerformance(cars) {
180
+ const overallBestSectors = createEmptySectorTimes();
181
+ cars.forEach((car) => {
182
+ car.lapTelemetry?.bestSectors?.forEach((time, index) => {
183
+ if (!Number.isFinite(time)) return;
184
+ const previousBest = overallBestSectors[index];
185
+ if (!Number.isFinite(previousBest) || time < previousBest) overallBestSectors[index] = time;
186
+ });
187
+ });
188
+
189
+ cars.forEach((car) => {
190
+ if (!car.lapTelemetry) return;
191
+ const telemetry = car.lapTelemetry;
192
+ telemetry.sectorPerformance = {
193
+ current: telemetry.currentSectors.map((time, index) => (
194
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
195
+ )),
196
+ last: telemetry.lastSectors.map((time, index) => (
197
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
198
+ )),
199
+ best: telemetry.bestSectors.map((time, index) => (
200
+ classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
201
+ )),
202
+ };
203
+ });
204
+ }
205
+
206
+ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, totalLaps) {
207
+ if (!car.lapTelemetry) resetLapTelemetry(car, currentTime, track, totalLaps);
208
+
209
+ const telemetry = car.lapTelemetry;
210
+ const currentRaceDistance = car.raceDistance ?? 0;
211
+ const previousDistance = Math.max(0, previousRaceDistance ?? currentRaceDistance);
212
+ const currentDistance = Math.max(0, currentRaceDistance);
213
+ const travelled = currentDistance - previousDistance;
214
+ const previousUpdateTime = Number.isFinite(telemetry.lastUpdatedAt) ? telemetry.lastUpdatedAt : currentTime;
215
+ const elapsedTime = Math.max(0, currentTime - previousUpdateTime);
216
+
217
+ if (!Number.isFinite(previousDistance) || !Number.isFinite(currentDistance) || travelled < -1e-3 || travelled > track.length / 2) {
218
+ resetLapTelemetry(car, currentTime, track, totalLaps);
219
+ return;
220
+ }
221
+
222
+ const sectorLength = getSectorLength(track);
223
+ if (travelled > 1e-6) {
224
+ const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
225
+ const lastBoundary = Math.floor(currentDistance / sectorLength);
226
+
227
+ for (let boundary = firstBoundary; boundary <= lastBoundary; boundary += 1) {
228
+ const boundaryDistance = boundary * sectorLength;
229
+ if (boundaryDistance <= previousDistance + 1e-3 || boundaryDistance > currentDistance + 1e-3) continue;
230
+
231
+ const boundaryRatio = clamp((boundaryDistance - previousDistance) / travelled, 0, 1);
232
+ const crossingTime = previousUpdateTime + boundaryRatio * elapsedTime;
233
+ const sectorIndex = (boundary - 1) % TELEMETRY_SECTOR_COUNT;
234
+ const sectorTime = Math.max(0, crossingTime - telemetry.currentSectorStartedAt);
235
+
236
+ telemetry.currentSectors[sectorIndex] = sectorTime;
237
+ updateBestSector(telemetry, sectorIndex, sectorTime);
238
+
239
+ if (sectorIndex === TELEMETRY_SECTOR_COUNT - 1) {
240
+ const lapTime = Math.max(0, crossingTime - telemetry.currentLapStartedAt);
241
+ telemetry.lastLapTime = lapTime;
242
+ telemetry.bestLapTime = Number.isFinite(telemetry.bestLapTime)
243
+ ? Math.min(telemetry.bestLapTime, lapTime)
244
+ : lapTime;
245
+ telemetry.lastSectors = serializeSectorTimes(telemetry.currentSectors);
246
+ telemetry.currentSectors = createEmptySectorTimes();
247
+ telemetry.currentLapStartedAt = crossingTime;
248
+ telemetry.completedLaps = Math.max(telemetry.completedLaps + 1, Math.min(Math.floor(boundaryDistance / track.length), totalLaps));
249
+ }
250
+
251
+ telemetry.currentSectorStartedAt = crossingTime;
252
+ }
253
+ }
254
+
255
+ syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps);
256
+ telemetry.lastUpdatedAt = currentTime;
257
+ }
258
+
60
259
  function createCar(driver, index, random, track, { standingStart = false } = {}) {
61
260
  const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
62
261
  const start = pointAt(track, gridDistance);
@@ -123,6 +322,7 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
123
322
  drsZoneId: null,
124
323
  drsZoneEnabled: false,
125
324
  timingHistory: [],
325
+ lapTelemetry: createLapTelemetry(track, 0, gridDistance),
126
326
  drsDetection: {},
127
327
  canAttack: true,
128
328
  trackState: start,
@@ -131,6 +331,12 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
131
331
  };
132
332
  }
133
333
 
334
+ function normalizeTotalLaps(value) {
335
+ const numeric = Number(value);
336
+ if (!Number.isFinite(numeric)) return MIN_TOTAL_LAPS;
337
+ return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
338
+ }
339
+
134
340
  function projectOntoAxis(points, axis) {
135
341
  let min = Infinity;
136
342
  let max = -Infinity;
@@ -345,6 +551,7 @@ function serializeCar(car, rank) {
345
551
  raceDistance: car.raceDistance,
346
552
  distanceMeters: simUnitsToMeters(car.raceDistance),
347
553
  lap: car.lap,
554
+ lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
348
555
  gapAhead: car.gapAhead,
349
556
  gapAheadMeters: Number.isFinite(car.gapAhead) ? simUnitsToMeters(car.gapAhead) : Infinity,
350
557
  gapAheadSeconds: car.gapAheadSeconds,
@@ -373,7 +580,7 @@ export class F1RaceSimulation {
373
580
  this.trackSeed = this.track.seed ?? trackSeed;
374
581
  this.rules = { ...DEFAULT_RULES, ...rules };
375
582
  this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
376
- this.totalLaps = totalLaps;
583
+ this.totalLaps = normalizeTotalLaps(totalLaps);
377
584
  this.time = 0;
378
585
  this.events = [];
379
586
  this.raceControl = {
@@ -409,6 +616,7 @@ export class F1RaceSimulation {
409
616
  }));
410
617
  this.recalculateRaceState({ updateDrs: false });
411
618
  this.cars.forEach((car) => resetTimingHistory(car, this.time));
619
+ this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
412
620
  }
413
621
 
414
622
  setSafetyCar(deployed) {
@@ -465,6 +673,7 @@ export class F1RaceSimulation {
465
673
  car.drsEligible = false;
466
674
  car.drsDetection = {};
467
675
  resetTimingHistory(car, this.time);
676
+ resetLapTelemetry(car, this.time, this.track, this.totalLaps);
468
677
  this.recalculateRaceState({ updateDrs: false });
469
678
  this.evaluateRaceFinish();
470
679
  }
@@ -687,12 +896,14 @@ export class F1RaceSimulation {
687
896
 
688
897
  recalculateRaceState({ updateDrs = true } = {}) {
689
898
  this.cars.forEach((car) => {
899
+ const previousRaceDistance = car.raceDistance;
690
900
  if (car.gridLocked) {
691
901
  const gridPoint = pointAt(this.track, car.gridDistance);
692
902
  car.trackState = nearestTrackState(this.track, car, car.gridDistance);
693
903
  car.progress = gridPoint.distance;
694
904
  car.raceDistance = car.gridDistance;
695
905
  car.lap = 1;
906
+ resetLapTelemetry(car, this.time, this.track, this.totalLaps);
696
907
  return;
697
908
  }
698
909
 
@@ -702,8 +913,10 @@ export class F1RaceSimulation {
702
913
  car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
703
914
  car.progress = car.trackState.distance;
704
915
  car.lap = this.computeLap(car.raceDistance);
916
+ updateLapTelemetry(car, previousRaceDistance, this.time, this.track, this.totalLaps);
705
917
  });
706
918
 
919
+ updateSectorPerformance(this.cars);
707
920
  this.cars.forEach((car) => recordTimingSample(car, this.time));
708
921
 
709
922
  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
  };