@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.
- package/CHANGELOG.md +10 -0
- package/README.md +20 -4
- package/docs/architecture.md +15 -6
- package/docs/concepts.md +5 -1
- package/docs/data_contract.md +85 -3
- package/docs/rules.md +2 -0
- package/docs/system_specs.md +29 -5
- package/package.json +1 -1
- package/src/api/PaddockSimulatorController.js +57 -1
- package/src/app/F1SimulatorApp.js +160 -22
- package/src/app/domBindings.js +36 -11
- package/src/config/defaultOptions.js +25 -0
- package/src/index.d.ts +57 -0
- package/src/index.js +6 -0
- package/src/simulation/raceSimulation.js +205 -0
- package/src/simulation/trackModel.js +21 -0
- package/src/styles.css +451 -2
- package/src/ui/componentTemplates.js +176 -3
|
@@ -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
|
};
|