@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.
- package/CHANGELOG.md +16 -0
- package/INSTALL_AND_UPDATE.md +163 -0
- package/README.md +96 -16
- package/docs/architecture.md +55 -11
- package/docs/concepts.md +5 -1
- package/docs/data_contract.md +324 -8
- package/docs/data_model.md +1 -0
- package/docs/index.md +1 -0
- package/docs/rules.md +2 -0
- package/docs/system_specs.md +66 -11
- package/docs/training.md +104 -0
- package/examples/train-basic-policy.mjs +277 -0
- package/package.json +14 -7
- package/src/api/PaddockSimulatorController.js +64 -3
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +308 -38
- package/src/app/domBindings.js +35 -11
- package/src/config/defaultOptions.js +33 -0
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +45 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +241 -0
- package/src/environment/index.js +2 -0
- package/src/environment/observations.js +112 -0
- package/src/environment/options.js +150 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +140 -0
- package/src/environment/sensors.js +257 -0
- package/src/environment/specs.js +79 -0
- package/src/index.d.ts +97 -4
- package/src/index.js +12 -2
- package/src/rendering/proceduralTrackAsset.js +7 -1
- package/src/simulation/raceSimulation.js +214 -1
- package/src/simulation/trackModel.js +21 -0
- package/src/styles.css +495 -2
- package/src/ui/componentTemplates.js +201 -4
- package/AGENTS.md +0 -86
- 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?:
|
|
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?:
|
|
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
|
-
|
|
94
|
-
app.restart(
|
|
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
|
|
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
|
};
|