@inventure71/paddockjs 0.3.0 → 1.0.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 (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL_AND_UPDATE.md +3 -1
  3. package/README.md +142 -9
  4. package/docs/architecture.md +49 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +185 -27
  7. package/docs/data_model.md +1 -1
  8. package/docs/learnings.md +1 -1
  9. package/docs/rules.md +140 -19
  10. package/docs/system_specs.md +39 -16
  11. package/docs/training.md +103 -1
  12. package/examples/train-basic-policy.mjs +5 -6
  13. package/examples/trainingData.mjs +133 -0
  14. package/package.json +5 -2
  15. package/src/api/PaddockSimulatorController.js +32 -2
  16. package/src/app/F1SimulatorApp.js +1101 -128
  17. package/src/app/domBindings.js +10 -1
  18. package/src/config/defaultOptions.js +4 -4
  19. package/src/environment/actions.js +25 -2
  20. package/src/environment/evaluation.js +184 -0
  21. package/src/environment/index.d.ts +281 -1
  22. package/src/environment/index.js +13 -0
  23. package/src/environment/observationOptions.js +12 -0
  24. package/src/environment/observations.js +189 -17
  25. package/src/environment/options.js +7 -0
  26. package/src/environment/recorder.js +28 -0
  27. package/src/environment/runtime.js +41 -6
  28. package/src/environment/scenarios.js +220 -0
  29. package/src/environment/sensors.js +145 -35
  30. package/src/environment/specs.js +92 -8
  31. package/src/environment/workerProtocol.js +52 -0
  32. package/src/index.d.ts +366 -3
  33. package/src/index.js +21 -0
  34. package/src/rendering/proceduralTrackAsset.js +336 -23
  35. package/src/simulation/collisionGeometry.js +214 -0
  36. package/src/simulation/driverController.js +431 -65
  37. package/src/simulation/raceSimulation.js +2132 -171
  38. package/src/simulation/rules/collisionSteward.js +69 -0
  39. package/src/simulation/rules/penaltyLedger.js +153 -0
  40. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  41. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  42. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  43. package/src/simulation/rulesConfig.js +352 -0
  44. package/src/simulation/trackModel.js +1154 -76
  45. package/src/simulation/units.js +6 -4
  46. package/src/simulation/vehicleGeometry.js +247 -0
  47. package/src/simulation/vehiclePhysics.js +82 -50
  48. package/src/simulation/wheelSurface.js +338 -0
  49. package/src/styles.css +300 -42
  50. package/src/ui/bannerTemplates.js +69 -0
  51. package/src/ui/componentTemplates.js +36 -96
  52. package/src/ui/raceControlStatusBanner.js +39 -0
  53. package/src/ui/shellTemplate.js +1 -1
  54. package/src/ui/templateUtils.js +23 -0
@@ -8,32 +8,79 @@ import {
8
8
  WORLD,
9
9
  } from './trackModel.js';
10
10
  import { buildDriverPersonality, decideDriverControls } from './driverController.js';
11
+ import { buildCollisionCandidatePairs, detectVehicleCollision } from './collisionGeometry.js';
12
+ import { calculateCollisionPenalties } from './rules/collisionSteward.js';
13
+ import {
14
+ applyUnservedServicePenalty,
15
+ cancelPenaltyRecord,
16
+ createPenaltyEvent,
17
+ createPenaltyRecord,
18
+ getPitServicePenaltySeconds,
19
+ isPenaltyActive,
20
+ isPenaltyPitServiceable,
21
+ serializePenalty,
22
+ servePitPenaltyRecord,
23
+ servePenaltyRecord,
24
+ } from './rules/penaltyLedger.js';
25
+ import { calculateTireRequirementPenalty } from './rules/tireRequirementSteward.js';
26
+ import { calculateTrackLimitReview } from './rules/trackLimitsSteward.js';
27
+ import { calculatePitLaneSpeedingReview } from './rules/pitLaneSpeedingSteward.js';
28
+ import { DEFAULT_RULES, getPenaltyRule, normalizeRaceRules } from './rulesConfig.js';
11
29
  import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
12
- import { simSpeedToKph, simUnitsToMeters } from './units.js';
13
- import { getCarCorners, integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
30
+ import { kphToSimSpeed, metersToSimUnits, simSpeedToKph, simUnitsToMeters } from './units.js';
31
+ import { integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
32
+ import { applyWheelSurfaceState } from './wheelSurface.js';
14
33
 
15
34
  const DEFAULT_TOTAL_LAPS = 10;
16
35
  export const FIXED_STEP = 1 / 60;
17
36
  const MIN_TOTAL_LAPS = 1;
18
37
  const MAX_COLLISION_CORRECTION = 4.5;
19
- const GRID_SLOT_SPACING = 82;
20
- const GRID_FIRST_SLOT_DISTANCE = -42;
21
- const GRID_LATERAL_OFFSET = 42;
38
+ const GRID_SLOT_SPACING = metersToSimUnits(8);
39
+ const ROLLING_START_SLOT_SPACING = metersToSimUnits(35);
40
+ const GRID_FIRST_SLOT_DISTANCE = metersToSimUnits(-6);
41
+ const ROLLING_START_FIRST_DISTANCE = metersToSimUnits(-30);
42
+ const GRID_LATERAL_OFFSET = metersToSimUnits(3.2);
22
43
  const TIMING_HISTORY_WINDOW_SECONDS = 18;
23
44
  const TIMING_HISTORY_MAX_SAMPLES = 720;
45
+ const TIMING_LINE_TARGET_SPACING_METERS = 175;
46
+ const TIMING_LINE_HISTORY_LAPS = 3;
24
47
  const TELEMETRY_SECTOR_COUNT = 3;
48
+ const PIT_ENTRY_APPROACH_DISTANCE = metersToSimUnits(250);
49
+ const PIT_ROUTE_LOOKAHEAD_MIN = metersToSimUnits(35);
50
+ const PIT_ROUTE_LOOKAHEAD_MAX = metersToSimUnits(88);
51
+ const PIT_ROUTE_FINISH_DISTANCE = metersToSimUnits(8.5);
52
+ const PIT_QUEUE_RELEASE_FINISH_DISTANCE = metersToSimUnits(4);
53
+ const PIT_QUEUE_CAPTURE_DISTANCE = metersToSimUnits(2.5);
54
+ const PIT_ENTRY_BOX_CAPTURE_DISTANCE = metersToSimUnits(20);
55
+ const PIT_BOX_STOP_SPEED = kphToSimSpeed(35);
56
+ const PIT_QUEUE_CAPTURE_SPEED = kphToSimSpeed(140);
57
+ const PIT_QUEUE_RELEASE_SPEED = kphToSimSpeed(30);
58
+ const PIT_SERVICE_CLEAR_DISTANCE = VEHICLE_LIMITS.carLength * 0.9;
59
+ const PIT_EXIT_RELEASE_SPEED_KPH = 95;
60
+ const PIT_LIMITER_BRAKE_DISTANCE = metersToSimUnits(295);
61
+ const PIT_LIMITER_APPROACH_SPEED_SLOPE = 0.045;
62
+ const PIT_ENTRY_CONNECTOR_OVERSPEED_KPH = 75;
63
+ const PIT_DRIVE_LANE_OFFSET_RATIO = 0.28;
64
+ const PIT_BOX_APPROACH_DISTANCE = metersToSimUnits(34);
65
+ const PIT_SERVICE_QUEUE_FALLBACK_GAP = metersToSimUnits(48);
66
+ const PIT_INTENT_NONE = 0;
67
+ const PIT_INTENT_IF_FREE = 1;
68
+ const PIT_INTENT_COMMITTED = 2;
69
+
70
+ export { DEFAULT_RULES };
71
+
72
+ function getStartGridSlot(index, { standingStart = false } = {}) {
73
+ const gridDistance = standingStart
74
+ ? GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING
75
+ : ROLLING_START_FIRST_DISTANCE - index * ROLLING_START_SLOT_SPACING;
76
+ const gridOffset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
77
+ return { gridDistance, gridOffset };
78
+ }
25
79
 
26
- export const DEFAULT_RULES = {
27
- drsDetectionSeconds: 1,
28
- safetyCarSpeed: 46,
29
- safetyCarLeadDistance: 122,
30
- safetyCarGap: 128,
31
- collisionRestitution: 0.18,
32
- standingStart: true,
33
- startLightCount: 5,
34
- startLightInterval: 0.72,
35
- startLightsOutHold: 0.78,
36
- };
80
+ function isPitPositionControlledCar(car) {
81
+ const status = car?.pitStop?.status;
82
+ return status === 'entering' || status === 'queued' || status === 'servicing' || status === 'exiting';
83
+ }
37
84
 
38
85
  function wrapProgress(value, length) {
39
86
  return wrapDistance(value, length);
@@ -97,6 +144,15 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
97
144
  };
98
145
  }
99
146
 
147
+ function createSectorProgress(position, currentSectors = null) {
148
+ const activeIndex = clamp((position.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
149
+ return createEmptySectorTimes().map((_, index) => {
150
+ if (index < activeIndex && Number.isFinite(currentSectors?.[index])) return 1;
151
+ if (index === activeIndex) return clamp(position.currentSectorProgress ?? 0, 0, 1);
152
+ return 0;
153
+ });
154
+ }
155
+
100
156
  function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
101
157
  const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
102
158
  return {
@@ -107,6 +163,8 @@ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps
107
163
  currentLapTime: 0,
108
164
  currentSectorElapsed: 0,
109
165
  currentSectors: createEmptySectorTimes(),
166
+ sectorProgress: createSectorProgress(position),
167
+ liveSectors: createEmptySectorTimes(),
110
168
  lastLapTime: null,
111
169
  bestLapTime: null,
112
170
  lastSectors: createEmptySectorTimes(),
@@ -135,6 +193,8 @@ function serializeLapTelemetry(telemetry) {
135
193
  currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
136
194
  currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
137
195
  currentSectors: serializeSectorTimes(telemetry.currentSectors),
196
+ sectorProgress: serializeSectorTimes(telemetry.sectorProgress),
197
+ liveSectors: serializeSectorTimes(telemetry.liveSectors),
138
198
  lastLapTime: finiteOrNull(telemetry.lastLapTime),
139
199
  bestLapTime: finiteOrNull(telemetry.bestLapTime),
140
200
  lastSectors: serializeSectorTimes(telemetry.lastSectors),
@@ -159,14 +219,36 @@ function updateBestSector(telemetry, sectorIndex, sectorTime) {
159
219
  }
160
220
  }
161
221
 
222
+ function clearFutureSectorTelemetry(telemetry) {
223
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
224
+ for (let index = activeIndex; index < TELEMETRY_SECTOR_COUNT; index += 1) {
225
+ telemetry.currentSectors[index] = null;
226
+ }
227
+ }
228
+
229
+ function syncLiveSectorTelemetry(telemetry, track) {
230
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
231
+
232
+ telemetry.liveSectors = createEmptySectorTimes();
233
+ telemetry.currentSectors.forEach((time, index) => {
234
+ if (index >= activeIndex) return;
235
+ if (!Number.isFinite(time)) return;
236
+ telemetry.liveSectors[index] = time;
237
+ });
238
+ telemetry.liveSectors[activeIndex] = telemetry.currentSectorElapsed;
239
+ }
240
+
162
241
  function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
163
242
  const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
164
243
  telemetry.currentLap = position.currentLap;
165
244
  telemetry.currentSector = position.currentSector;
166
245
  telemetry.currentSectorProgress = position.currentSectorProgress;
246
+ clearFutureSectorTelemetry(telemetry);
247
+ telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
167
248
  telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
168
249
  telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
169
250
  telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
251
+ syncLiveSectorTelemetry(telemetry, track);
170
252
  }
171
253
 
172
254
  function classifySectorPerformance(value, personalBest, overallBest) {
@@ -257,14 +339,13 @@ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, total
257
339
  }
258
340
 
259
341
  function createCar(driver, index, random, track, { standingStart = false } = {}) {
260
- const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
342
+ const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart });
261
343
  const start = pointAt(track, gridDistance);
262
- const offset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
263
- const position = offsetTrackPoint(start, offset);
344
+ const position = offsetTrackPoint(start, gridOffset);
264
345
  const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
265
346
  const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
266
347
  const personality = buildDriverPersonality(driver, index, racecraft, random);
267
- const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
348
+ const launchSpeed = kphToSimSpeed(Math.max(70, 84 - index * 0.55 + pace * 4));
268
349
  const vehicle = driver.vehicle ?? {};
269
350
 
270
351
  return {
@@ -307,8 +388,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
307
388
  aggression: personality.baseAggression,
308
389
  gridLocked: standingStart,
309
390
  gridDistance,
310
- gridOffset: offset,
311
- desiredOffset: offset,
391
+ gridOffset,
392
+ desiredOffset: gridOffset,
312
393
  progress: start.distance,
313
394
  raceDistance: gridDistance,
314
395
  lap: 1,
@@ -317,6 +398,9 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
317
398
  gapAheadSeconds: Infinity,
318
399
  leaderGapSeconds: 0,
319
400
  intervalAheadSeconds: Infinity,
401
+ gapAheadLaps: 0,
402
+ intervalAheadLaps: 0,
403
+ leaderGapLaps: 0,
320
404
  drsEligible: false,
321
405
  drsActive: false,
322
406
  drsZoneId: null,
@@ -328,6 +412,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
328
412
  trackState: start,
329
413
  contactCooldown: 0,
330
414
  tireEnergy: 100,
415
+ usedTireCompounds: [driver.tire ?? ['M', 'H', 'S'][index % 3]],
416
+ automaticPitIntentEnabled: true,
331
417
  };
332
418
  }
333
419
 
@@ -337,82 +423,59 @@ function normalizeTotalLaps(value) {
337
423
  return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
338
424
  }
339
425
 
340
- function projectOntoAxis(points, axis) {
341
- let min = Infinity;
342
- let max = -Infinity;
343
- points.forEach((point) => {
344
- const projection = point.x * axis.x + point.y * axis.y;
345
- min = Math.min(min, projection);
346
- max = Math.max(max, projection);
347
- });
348
- return { min, max };
349
- }
350
-
351
- function overlapOnAxis(a, b, axis) {
352
- const first = projectOntoAxis(a, axis);
353
- const second = projectOntoAxis(b, axis);
354
- return Math.min(first.max, second.max) - Math.max(first.min, second.min);
355
- }
356
-
357
- function detectObbCollision(a, b) {
358
- const aCorners = getCarCorners(a);
359
- const bCorners = getCarCorners(b);
360
- const axes = [
361
- normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
362
- normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
363
- normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
364
- normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
365
- ];
366
-
367
- let minimumOverlap = Infinity;
368
- let minimumAxis = null;
369
-
370
- for (const axis of axes) {
371
- const overlap = overlapOnAxis(aCorners, bCorners, axis);
372
- if (overlap <= 0) return null;
373
- if (overlap < minimumOverlap) {
374
- minimumOverlap = overlap;
375
- minimumAxis = axis;
376
- }
377
- }
378
-
379
- const direction = { x: b.x - a.x, y: b.y - a.y };
380
- if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
381
- minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
382
- }
383
-
384
- return { axis: minimumAxis, depth: minimumOverlap };
426
+ function forwardVector(car) {
427
+ return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
385
428
  }
386
429
 
387
- function detectLongitudinalCollision(a, b) {
388
- const longitudinalGap = b.raceDistance - a.raceDistance;
389
- const headingDelta = Math.abs(normalizeAngle(a.heading - b.heading));
390
- const blendedHeading = a.heading + normalizeAngle(b.heading - a.heading) * 0.5;
391
- let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
392
- const direction = { x: b.x - a.x, y: b.y - a.y };
393
- const physicalLongitudinalGap = dot(direction, axis);
394
- if (physicalLongitudinalGap < 0) axis = { x: -axis.x, y: -axis.y };
430
+ function velocityVector(car) {
431
+ const forward = forwardVector(car);
432
+ return { x: forward.x * car.speed, y: forward.y * car.speed };
433
+ }
395
434
 
396
- const physicalLongitudinalSeparation = Math.abs(physicalLongitudinalGap);
397
- const lateralGap = Math.abs(direction.x * -axis.y + direction.y * axis.x);
398
- const requiredGap = VEHICLE_LIMITS.carLength * 0.96;
435
+ function createCollisionStewardContext(first, second, collision) {
436
+ const distanceDelta = collision.trackLength
437
+ ? progressDelta(second.progress ?? second.raceDistance ?? 0, first.progress ?? first.raceDistance ?? 0, collision.trackLength)
438
+ : (second.raceDistance ?? 0) - (first.raceDistance ?? 0);
439
+ const sideBySideTolerance = VEHICLE_LIMITS.carLength * 0.18;
440
+ if (Math.abs(distanceDelta) <= sideBySideTolerance) {
441
+ const firstVelocity = velocityVector(first);
442
+ const secondVelocity = velocityVector(second);
443
+ const relativeVelocity = {
444
+ x: firstVelocity.x - secondVelocity.x,
445
+ y: firstVelocity.y - secondVelocity.y,
446
+ };
447
+ return {
448
+ ...collision,
449
+ impactSpeed: Math.hypot(relativeVelocity.x, relativeVelocity.y),
450
+ aheadDriverId: null,
451
+ atFaultDriverId: null,
452
+ sharedFault: true,
453
+ sharedFaultDriverIds: [first.id, second.id],
454
+ };
455
+ }
399
456
 
400
- if (Math.abs(longitudinalGap) > VEHICLE_LIMITS.carLength * 1.45) return null;
401
- if (physicalLongitudinalSeparation >= requiredGap) return null;
402
- if (lateralGap > VEHICLE_LIMITS.carWidth * 0.82) return null;
403
- if (headingDelta > 0.58) return null;
457
+ const firstBehind = distanceDelta > 0;
458
+ const behind = firstBehind ? first : second;
459
+ const ahead = firstBehind ? second : first;
460
+ const directionBehindToAhead = normalizeVector({
461
+ x: ahead.x - behind.x,
462
+ y: ahead.y - behind.y,
463
+ });
464
+ const behindVelocity = velocityVector(behind);
465
+ const aheadVelocity = velocityVector(ahead);
466
+ const relativeVelocity = {
467
+ x: behindVelocity.x - aheadVelocity.x,
468
+ y: behindVelocity.y - aheadVelocity.y,
469
+ };
404
470
 
405
471
  return {
406
- axis,
407
- depth: requiredGap - physicalLongitudinalSeparation,
408
- longitudinal: true,
472
+ ...collision,
473
+ impactSpeed: Math.max(0, dot(relativeVelocity, directionBehindToAhead)),
474
+ aheadDriverId: ahead.id,
475
+ atFaultDriverId: behind.id,
409
476
  };
410
477
  }
411
478
 
412
- function forwardVector(car) {
413
- return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
414
- }
415
-
416
479
  function dot(a, b) {
417
480
  return a.x * b.x + a.y * b.y;
418
481
  }
@@ -477,9 +540,12 @@ function interpolateTimeAtDistance(history, targetDistance) {
477
540
  return null;
478
541
  }
479
542
 
480
- function estimateGapAheadSeconds(ahead, car, currentTime) {
543
+ function estimateGapAheadSeconds(ahead, car, currentTime, track) {
481
544
  if (!ahead) return Infinity;
482
545
 
546
+ const timingLineGap = estimateTimingLineGapSeconds(ahead, car, currentTime, track);
547
+ if (Number.isFinite(timingLineGap)) return timingLineGap;
548
+
483
549
  const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
484
550
  if (Number.isFinite(crossingTime)) {
485
551
  return Math.max(0, currentTime - crossingTime);
@@ -489,6 +555,106 @@ function estimateGapAheadSeconds(ahead, car, currentTime) {
489
555
  return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
490
556
  }
491
557
 
558
+ function wholeLapGap(aheadDistance, carDistance, trackLength) {
559
+ if (!Number.isFinite(aheadDistance) || !Number.isFinite(carDistance)) return 0;
560
+ if (!Number.isFinite(trackLength) || trackLength <= 0) return 0;
561
+ return Math.max(0, Math.floor((aheadDistance - carDistance + 1e-6) / trackLength));
562
+ }
563
+
564
+ function createTimingLines(track) {
565
+ const targetSpacing = metersToSimUnits(TIMING_LINE_TARGET_SPACING_METERS);
566
+ const count = Math.max(1, Math.round(track.length / targetSpacing));
567
+ const spacing = track.length / count;
568
+
569
+ return {
570
+ spacing,
571
+ spacingMeters: simUnitsToMeters(spacing),
572
+ count,
573
+ lines: Array.from({ length: count }, (_, index) => {
574
+ const distance = index * spacing;
575
+ const point = pointAt(track, distance);
576
+ return {
577
+ index,
578
+ distance,
579
+ distanceMeters: simUnitsToMeters(distance),
580
+ x: point.x,
581
+ y: point.y,
582
+ heading: point.heading,
583
+ };
584
+ }),
585
+ };
586
+ }
587
+
588
+ function getTimingLineNumber(track, raceDistance) {
589
+ const spacing = track.timingLines?.spacing;
590
+ if (!Number.isFinite(spacing) || spacing <= 0 || !Number.isFinite(raceDistance)) return null;
591
+ return Math.floor(Math.max(0, raceDistance) / spacing);
592
+ }
593
+
594
+ function resetTimingLineCrossings(car, currentTime) {
595
+ car.timingLineCrossings = Object.create(null);
596
+ car.timingLineLastUpdatedAt = currentTime;
597
+ car.previousRaceDistanceForTiming = car.raceDistance;
598
+ }
599
+
600
+ function recordTimingLineCrossings(car, previousRaceDistance, currentTime, track) {
601
+ const spacing = track.timingLines?.spacing;
602
+ if (!Number.isFinite(spacing) || spacing <= 0 || !Number.isFinite(car.raceDistance)) return;
603
+ if (!car.timingLineCrossings || typeof car.timingLineCrossings !== 'object') {
604
+ resetTimingLineCrossings(car, currentTime);
605
+ return;
606
+ }
607
+
608
+ const previousDistance = Number.isFinite(previousRaceDistance) ? previousRaceDistance : car.raceDistance;
609
+ const currentDistance = car.raceDistance;
610
+ const travelled = currentDistance - previousDistance;
611
+ const previousTime = Number.isFinite(car.timingLineLastUpdatedAt) ? car.timingLineLastUpdatedAt : currentTime;
612
+ const elapsed = Math.max(0, currentTime - previousTime);
613
+
614
+ if (travelled > 1e-6 && travelled < track.length / 2) {
615
+ const firstLine = Math.floor(Math.max(0, previousDistance) / spacing) + 1;
616
+ const lastLine = Math.floor(Math.max(0, currentDistance) / spacing);
617
+
618
+ for (let lineNumber = firstLine; lineNumber <= lastLine; lineNumber += 1) {
619
+ const lineDistance = lineNumber * spacing;
620
+ if (lineDistance <= previousDistance + 1e-6 || lineDistance > currentDistance + 1e-6) continue;
621
+ const ratio = clamp((lineDistance - previousDistance) / travelled, 0, 1);
622
+ car.timingLineCrossings[lineNumber] = previousTime + ratio * elapsed;
623
+ }
624
+ }
625
+
626
+ car.timingLineLastUpdatedAt = currentTime;
627
+ const latestLine = getTimingLineNumber(track, currentDistance);
628
+ if (latestLine != null) {
629
+ const cutoff = latestLine - (track.timingLines.count * TIMING_LINE_HISTORY_LAPS);
630
+ Object.keys(car.timingLineCrossings).forEach((key) => {
631
+ if (Number(key) < cutoff) delete car.timingLineCrossings[key];
632
+ });
633
+ }
634
+ }
635
+
636
+ function getTimingLineCrossingTime(car, lineNumber) {
637
+ const value = car?.timingLineCrossings?.[lineNumber];
638
+ return Number.isFinite(value) ? value : null;
639
+ }
640
+
641
+ function estimateTimingLineGapSeconds(ahead, car, currentTime, track) {
642
+ if (!ahead || !car) return Infinity;
643
+ const currentLine = getTimingLineNumber(track, Math.min(ahead.raceDistance ?? 0, car.raceDistance ?? 0));
644
+ if (currentLine == null) return null;
645
+ const oldestLine = Math.max(0, currentLine - (track.timingLines?.count ?? 0));
646
+
647
+ for (let lineNumber = currentLine; lineNumber >= oldestLine; lineNumber -= 1) {
648
+ const aheadTime = getTimingLineCrossingTime(ahead, lineNumber);
649
+ const carTime = getTimingLineCrossingTime(car, lineNumber);
650
+ if (Number.isFinite(aheadTime) && Number.isFinite(carTime)) {
651
+ return Math.max(0, carTime - aheadTime);
652
+ }
653
+ }
654
+
655
+ return null;
656
+ }
657
+
492
658
  function recordDrsDetection(car, zoneId, currentTime) {
493
659
  const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
494
660
  const next = { passage: previous.passage + 1, time: currentTime };
@@ -499,7 +665,403 @@ function recordDrsDetection(car, zoneId, currentTime) {
499
665
  return next;
500
666
  }
501
667
 
502
- function serializeCar(car, rank) {
668
+ function clonePointLike(point) {
669
+ if (!point) return point;
670
+ return { ...point };
671
+ }
672
+
673
+ function clonePointArray(points) {
674
+ return Array.isArray(points) ? points.map(clonePointLike) : points;
675
+ }
676
+
677
+ function clonePitLaneModel(pitLane) {
678
+ if (!pitLane) return pitLane;
679
+ return {
680
+ ...pitLane,
681
+ entry: pitLane.entry ? {
682
+ ...pitLane.entry,
683
+ trackPoint: clonePointLike(pitLane.entry.trackPoint),
684
+ edgePoint: clonePointLike(pitLane.entry.edgePoint),
685
+ trackConnectPoint: clonePointLike(pitLane.entry.trackConnectPoint),
686
+ lanePoint: clonePointLike(pitLane.entry.lanePoint),
687
+ roadCenterline: clonePointArray(pitLane.entry.roadCenterline),
688
+ connector: clonePointArray(pitLane.entry.connector),
689
+ } : pitLane.entry,
690
+ exit: pitLane.exit ? {
691
+ ...pitLane.exit,
692
+ trackPoint: clonePointLike(pitLane.exit.trackPoint),
693
+ edgePoint: clonePointLike(pitLane.exit.edgePoint),
694
+ trackConnectPoint: clonePointLike(pitLane.exit.trackConnectPoint),
695
+ lanePoint: clonePointLike(pitLane.exit.lanePoint),
696
+ roadCenterline: clonePointArray(pitLane.exit.roadCenterline),
697
+ connector: clonePointArray(pitLane.exit.connector),
698
+ } : pitLane.exit,
699
+ mainLane: pitLane.mainLane ? {
700
+ ...pitLane.mainLane,
701
+ start: clonePointLike(pitLane.mainLane.start),
702
+ end: clonePointLike(pitLane.mainLane.end),
703
+ points: clonePointArray(pitLane.mainLane.points),
704
+ } : pitLane.mainLane,
705
+ workingLane: pitLane.workingLane ? {
706
+ ...pitLane.workingLane,
707
+ start: clonePointLike(pitLane.workingLane.start),
708
+ end: clonePointLike(pitLane.workingLane.end),
709
+ points: clonePointArray(pitLane.workingLane.points),
710
+ } : pitLane.workingLane,
711
+ fastLane: pitLane.fastLane ? { ...pitLane.fastLane } : pitLane.fastLane,
712
+ serviceNormal: clonePointLike(pitLane.serviceNormal),
713
+ boxes: Array.isArray(pitLane.boxes)
714
+ ? pitLane.boxes.map((box) => ({
715
+ ...box,
716
+ laneTarget: clonePointLike(box.laneTarget),
717
+ center: clonePointLike(box.center),
718
+ corners: clonePointArray(box.corners),
719
+ }))
720
+ : pitLane.boxes,
721
+ serviceAreas: Array.isArray(pitLane.serviceAreas)
722
+ ? pitLane.serviceAreas.map((area) => ({
723
+ ...area,
724
+ laneTarget: clonePointLike(area.laneTarget),
725
+ center: clonePointLike(area.center),
726
+ queuePoint: clonePointLike(area.queuePoint),
727
+ corners: clonePointArray(area.corners),
728
+ queueCorners: clonePointArray(area.queueCorners),
729
+ garageBoxIds: [...(area.garageBoxIds ?? [])],
730
+ pitCrew: area.pitCrew ? { ...area.pitCrew } : area.pitCrew,
731
+ }))
732
+ : pitLane.serviceAreas,
733
+ teams: Array.isArray(pitLane.teams)
734
+ ? pitLane.teams.map((team) => ({ ...team, boxIds: [...(team.boxIds ?? [])], pitCrew: team.pitCrew ? { ...team.pitCrew } : team.pitCrew }))
735
+ : pitLane.teams,
736
+ };
737
+ }
738
+
739
+ function routePoint(point, heading = point?.heading, options = {}) {
740
+ if (!point) return null;
741
+ return {
742
+ x: point.x,
743
+ y: point.y,
744
+ heading,
745
+ limiterActive: Boolean(options.limiterActive ?? point.limiterActive),
746
+ };
747
+ }
748
+
749
+ function offsetPitLanePoint(pitLane, point, lateralOffset) {
750
+ return {
751
+ ...point,
752
+ x: point.x + pitLane.serviceNormal.x * lateralOffset,
753
+ y: point.y + pitLane.serviceNormal.y * lateralOffset,
754
+ heading: pitLane.mainLane.heading,
755
+ };
756
+ }
757
+
758
+ function pitMainLanePointAt(pitLane, distanceAlongLane, lateralOffset = 0) {
759
+ const amount = pitLane.mainLane.length > 0
760
+ ? clamp(distanceAlongLane / pitLane.mainLane.length, 0, 1)
761
+ : 0;
762
+ const point = {
763
+ x: pitLane.mainLane.start.x + (pitLane.mainLane.end.x - pitLane.mainLane.start.x) * amount,
764
+ y: pitLane.mainLane.start.y + (pitLane.mainLane.end.y - pitLane.mainLane.start.y) * amount,
765
+ heading: pitLane.mainLane.heading,
766
+ };
767
+ return offsetPitLanePoint(pitLane, point, lateralOffset);
768
+ }
769
+
770
+ function pitDriveLaneOffset(pitLane) {
771
+ return pitLane.fastLane?.offset ?? 0;
772
+ }
773
+
774
+ function sameRoutePoint(first, second) {
775
+ return first && second && Math.hypot(first.x - second.x, first.y - second.y) < 0.001;
776
+ }
777
+
778
+ function shiftPreviousRenderPose(car, dx, dy, dheading = 0) {
779
+ car.previousX = Number.isFinite(car.previousX) ? car.previousX + dx : car.x + dx;
780
+ car.previousY = Number.isFinite(car.previousY) ? car.previousY + dy : car.y + dy;
781
+ car.previousHeading = normalizeAngle(
782
+ (Number.isFinite(car.previousHeading) ? car.previousHeading : car.heading) + dheading,
783
+ );
784
+ }
785
+
786
+ function pointDistance(first, second) {
787
+ if (!first || !second) return Infinity;
788
+ return Math.hypot(first.x - second.x, first.y - second.y);
789
+ }
790
+
791
+ function createRoute(points) {
792
+ const routePoints = points
793
+ .filter(Boolean)
794
+ .map((point) => routePoint(point))
795
+ .filter(Boolean)
796
+ .reduce((deduped, point) => {
797
+ if (!sameRoutePoint(deduped.at(-1), point)) {
798
+ deduped.push(point);
799
+ } else if (point.limiterActive) {
800
+ deduped.at(-1).limiterActive = true;
801
+ }
802
+ return deduped;
803
+ }, []);
804
+ const segments = [];
805
+ let totalLength = 0;
806
+
807
+ for (let index = 0; index < routePoints.length - 1; index += 1) {
808
+ const start = routePoints[index];
809
+ const end = routePoints[index + 1];
810
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
811
+ if (length <= 0.001) continue;
812
+ const heading = Math.atan2(end.y - start.y, end.x - start.x);
813
+ segments.push({
814
+ start,
815
+ end,
816
+ length,
817
+ heading,
818
+ startDistance: totalLength,
819
+ endDistance: totalLength + length,
820
+ limiterActive: Boolean(start.limiterActive && end.limiterActive),
821
+ });
822
+ totalLength += length;
823
+ }
824
+
825
+ let nextLimiterStartDistance = Infinity;
826
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
827
+ const segment = segments[index];
828
+ if (segment.limiterActive) nextLimiterStartDistance = segment.startDistance;
829
+ segment.nextLimiterStartDistance = nextLimiterStartDistance;
830
+ }
831
+
832
+ return {
833
+ points: routePoints,
834
+ segments,
835
+ length: totalLength,
836
+ segmentCursor: 0,
837
+ };
838
+ }
839
+
840
+ function findRouteSegment(route, distanceAlong, endPadding = 0) {
841
+ if (!route?.segments?.length) return null;
842
+ const clampedDistance = clamp(distanceAlong, 0, route.length);
843
+ const segments = route.segments;
844
+ let index = clamp(Math.floor(route.segmentCursor ?? 0), 0, segments.length - 1);
845
+ let segment = segments[index];
846
+
847
+ if (
848
+ clampedDistance >= segment.startDistance - 0.001 &&
849
+ clampedDistance <= segment.endDistance - endPadding
850
+ ) {
851
+ return segment;
852
+ }
853
+
854
+ if (clampedDistance > segment.endDistance - endPadding) {
855
+ while (index < segments.length - 1 && clampedDistance > segments[index].endDistance - endPadding) {
856
+ index += 1;
857
+ }
858
+ } else {
859
+ while (index > 0 && clampedDistance < segments[index].startDistance - 0.001) {
860
+ index -= 1;
861
+ }
862
+ }
863
+
864
+ segment = segments[index];
865
+ if (
866
+ clampedDistance < segment.startDistance - 0.001 ||
867
+ clampedDistance > segment.endDistance - endPadding
868
+ ) {
869
+ segment = segments.find((candidate) => (
870
+ clampedDistance >= candidate.startDistance - 0.001 &&
871
+ clampedDistance <= candidate.endDistance - endPadding
872
+ )) ?? segments.at(-1);
873
+ index = segments.indexOf(segment);
874
+ }
875
+
876
+ route.segmentCursor = Math.max(0, index);
877
+ return segment;
878
+ }
879
+
880
+ function sampleRoute(route, distanceAlong) {
881
+ if (!route?.segments?.length) return route?.points?.[0] ?? null;
882
+ const clampedDistance = clamp(distanceAlong, 0, route.length);
883
+ const segment = findRouteSegment(route, clampedDistance) ?? route.segments.at(-1);
884
+ const amount = clamp((clampedDistance - segment.startDistance) / segment.length, 0, 1);
885
+
886
+ return {
887
+ x: segment.start.x + (segment.end.x - segment.start.x) * amount,
888
+ y: segment.start.y + (segment.end.y - segment.start.y) * amount,
889
+ heading: segment.heading,
890
+ limiterActive: segment.limiterActive,
891
+ };
892
+ }
893
+
894
+ function routeLimiterActiveAt(route, distanceAlong) {
895
+ if (!route?.segments?.length) return false;
896
+ const clampedDistance = clamp(distanceAlong, 0, route.length);
897
+ const segment = findRouteSegment(route, clampedDistance, 0.001) ?? route.segments.at(-1);
898
+ return Boolean(segment?.limiterActive);
899
+ }
900
+
901
+ function distanceToNextLimiterSegment(route, distanceAlong) {
902
+ if (!route?.segments?.length) return Infinity;
903
+ const clampedDistance = clamp(distanceAlong, 0, route.length);
904
+ const segment = findRouteSegment(route, clampedDistance, 0.001);
905
+ if (!segment) return Infinity;
906
+ if (segment.limiterActive && segment.endDistance > clampedDistance + 0.001) return 0;
907
+ return Math.max(0, segment.nextLimiterStartDistance - clampedDistance);
908
+ }
909
+
910
+ function easeInOut(value) {
911
+ const amount = clamp(value, 0, 1);
912
+ return amount * amount * (3 - 2 * amount);
913
+ }
914
+
915
+ function projectPositionToSegment(position, segment) {
916
+ const dx = segment.end.x - segment.start.x;
917
+ const dy = segment.end.y - segment.start.y;
918
+ const lengthSquared = dx * dx + dy * dy;
919
+ const amount = lengthSquared > 0
920
+ ? clamp(((position.x - segment.start.x) * dx + (position.y - segment.start.y) * dy) / lengthSquared, 0, 1)
921
+ : 0;
922
+ const x = segment.start.x + dx * amount;
923
+ const y = segment.start.y + dy * amount;
924
+ const distanceSquared = (position.x - x) ** 2 + (position.y - y) ** 2;
925
+
926
+ return {
927
+ distanceAlong: segment.startDistance + segment.length * amount,
928
+ distanceSquared,
929
+ };
930
+ }
931
+
932
+ function nearestDistanceOnRoute(route, position, previousDistance = 0) {
933
+ if (!route?.segments?.length) return 0;
934
+ let best = null;
935
+ const backtrackAllowance = metersToSimUnits(90);
936
+
937
+ route.segments.forEach((segment) => {
938
+ if (segment.endDistance < previousDistance - backtrackAllowance) return;
939
+ const projected = projectPositionToSegment(position, segment);
940
+ if (!best || projected.distanceSquared < best.distanceSquared) best = projected;
941
+ });
942
+
943
+ return clamp(best?.distanceAlong ?? previousDistance, 0, route.length);
944
+ }
945
+
946
+ function createPitApproachPoints(track, car, pitLane, entryRaceDistance) {
947
+ const currentDistance = car.raceDistance ?? entryRaceDistance - PIT_ENTRY_APPROACH_DISTANCE;
948
+ const remaining = Math.max(0, entryRaceDistance - currentDistance);
949
+ const currentState = nearestTrackState(track, car, car.progress);
950
+ const startOffset = currentState.inPitLane ? 0 : currentState.signedOffset ?? 0;
951
+ const entryConnectState = nearestTrackState(track, pitLane.entry.trackConnectPoint, pitLane.entry.trackDistance);
952
+ const targetOffset = entryConnectState.signedOffset ?? 0;
953
+ const points = [routePoint(car, car.heading)];
954
+ const straightDistance = remaining > metersToSimUnits(14)
955
+ ? Math.min(remaining * 0.22, metersToSimUnits(42))
956
+ : 0;
957
+ if (straightDistance > metersToSimUnits(1)) {
958
+ points.push(routePoint({
959
+ x: car.x + Math.cos(car.heading) * straightDistance,
960
+ y: car.y + Math.sin(car.heading) * straightDistance,
961
+ }, car.heading));
962
+ }
963
+
964
+ const transitionDistance = Math.max(0, remaining - straightDistance);
965
+ const steps = Math.max(4, Math.ceil(Math.max(transitionDistance, PIT_ENTRY_APPROACH_DISTANCE * 0.45) / metersToSimUnits(74)));
966
+ for (let index = 1; index <= steps; index += 1) {
967
+ const amount = index / steps;
968
+ const distance = currentDistance + straightDistance + transitionDistance * amount;
969
+ const trackPoint = pointAt(track, distance);
970
+ const lateralAmount = easeInOut(amount);
971
+ const offset = startOffset + (targetOffset - startOffset) * lateralAmount;
972
+ const point = offsetTrackPoint(trackPoint, offset);
973
+ points.push(routePoint(point, trackPoint.heading));
974
+ }
975
+
976
+ points.push(routePoint(pitLane.entry.trackConnectPoint, pitLane.entry.trackPoint?.heading));
977
+ return points;
978
+ }
979
+
980
+ function firstDifferentCompound(currentTire, compounds) {
981
+ const available = Array.isArray(compounds) && compounds.length ? compounds : ['S', 'M', 'H'];
982
+ return available.find((compound) => compound !== currentTire) ?? currentTire;
983
+ }
984
+
985
+ function normalizePitCompound(value, compounds) {
986
+ if (value == null || value === '') return null;
987
+ const available = Array.isArray(compounds) && compounds.length ? compounds : ['S', 'M', 'H'];
988
+ return available.includes(value) ? value : null;
989
+ }
990
+
991
+ function normalizePitIntent(value) {
992
+ const number = Number(value);
993
+ if (!Number.isInteger(number) || number < PIT_INTENT_NONE || number > PIT_INTENT_COMMITTED) return null;
994
+ return number;
995
+ }
996
+
997
+ function normalizePitCrewStats(value) {
998
+ const source = value && typeof value === 'object' ? value : {};
999
+ const rating = (entry) => {
1000
+ const numeric = Number(entry);
1001
+ return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : 0.5;
1002
+ };
1003
+ return {
1004
+ speed: rating(source.speed ?? source.pace),
1005
+ consistency: rating(source.consistency),
1006
+ reliability: rating(source.reliability),
1007
+ };
1008
+ }
1009
+
1010
+ function pitLaneStatusSnapshot(raceControl, pitLane, pitStops) {
1011
+ const enabled = Boolean(pitLane?.enabled && pitStops?.enabled);
1012
+ const redFlag = Boolean(raceControl.redFlag);
1013
+ const open = enabled && Boolean(raceControl.pitLaneOpen) && !redFlag;
1014
+ return {
1015
+ enabled,
1016
+ open,
1017
+ reason: !enabled ? 'unavailable' : redFlag ? 'red-flag' : open ? 'open' : 'closed',
1018
+ color: open ? 'green' : redFlag ? 'yellow' : 'red',
1019
+ light: open ? '#22c55e' : redFlag ? '#facc15' : '#ef4444',
1020
+ };
1021
+ }
1022
+
1023
+ function serializePitStop(pitStop) {
1024
+ if (!pitStop) return null;
1025
+ return {
1026
+ status: pitStop.status,
1027
+ intent: normalizePitIntent(pitStop.intent) ?? PIT_INTENT_NONE,
1028
+ phase: pitStop.phase ?? null,
1029
+ boxIndex: pitStop.boxIndex,
1030
+ boxId: pitStop.boxId,
1031
+ garageBoxIndex: pitStop.garageBoxIndex ?? null,
1032
+ garageBoxId: pitStop.garageBoxId ?? null,
1033
+ teamId: pitStop.teamId ?? null,
1034
+ teamColor: pitStop.teamColor ?? null,
1035
+ stopsCompleted: pitStop.stopsCompleted ?? 0,
1036
+ queueingForService: Boolean(pitStop.queueingForService),
1037
+ plannedRaceDistance: finiteOrNull(pitStop.plannedRaceDistance),
1038
+ entryRaceDistance: finiteOrNull(pitStop.entryRaceDistance),
1039
+ serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
1040
+ penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
1041
+ penaltyServiceTotalSeconds: finiteOrNull(pitStop.penaltyServiceTotal),
1042
+ servingPenaltyIds: [...(pitStop.servingPenaltyIds ?? [])],
1043
+ targetTire: pitStop.targetTire ?? null,
1044
+ serviceProfile: pitStop.serviceProfile ? { ...pitStop.serviceProfile } : null,
1045
+ };
1046
+ }
1047
+
1048
+ function serializeWheels(wheels = []) {
1049
+ return wheels.map((wheel) => ({
1050
+ id: wheel.id,
1051
+ x: wheel.x,
1052
+ y: wheel.y,
1053
+ signedOffset: wheel.signedOffset,
1054
+ crossTrackError: wheel.crossTrackError,
1055
+ surface: wheel.surface,
1056
+ onTrack: Boolean(wheel.onTrack),
1057
+ inPitLane: Boolean(wheel.inPitLane),
1058
+ fullyOutsideWhiteLine: Boolean(wheel.fullyOutsideWhiteLine),
1059
+ }));
1060
+ }
1061
+
1062
+ function serializeCar(car, rank, penaltySeconds = 0) {
1063
+ const finishTime = car.finishTime ?? null;
1064
+ const raceStatus = car.finished ? 'waved-flag' : 'racing';
503
1065
  return {
504
1066
  id: car.id,
505
1067
  code: car.code,
@@ -531,8 +1093,14 @@ function serializeCar(car, rank) {
531
1093
  },
532
1094
  rank,
533
1095
  classifiedRank: car.classifiedRank ?? rank,
1096
+ finishRank: car.finishRank ?? null,
1097
+ status: raceStatus,
1098
+ raceStatus,
1099
+ wavedFlag: Boolean(car.finished),
534
1100
  finished: Boolean(car.finished),
535
- finishTime: car.finishTime ?? null,
1101
+ finishTime,
1102
+ penaltySeconds,
1103
+ adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
536
1104
  previousX: car.previousX ?? car.x,
537
1105
  previousY: car.previousY ?? car.y,
538
1106
  x: car.x,
@@ -557,6 +1125,9 @@ function serializeCar(car, rank) {
557
1125
  gapAheadSeconds: car.gapAheadSeconds,
558
1126
  intervalAheadSeconds: car.intervalAheadSeconds,
559
1127
  leaderGapSeconds: car.leaderGapSeconds,
1128
+ gapAheadLaps: car.gapAheadLaps ?? 0,
1129
+ intervalAheadLaps: car.intervalAheadLaps ?? car.gapAheadLaps ?? 0,
1130
+ leaderGapLaps: car.leaderGapLaps ?? 0,
560
1131
  drsEligible: car.drsEligible,
561
1132
  drsActive: car.drsActive,
562
1133
  drsZoneId: car.drsZoneId,
@@ -565,27 +1136,161 @@ function serializeCar(car, rank) {
565
1136
  signedOffset: car.trackState?.signedOffset ?? 0,
566
1137
  crossTrackError: car.trackState?.crossTrackError ?? 0,
567
1138
  surface: car.trackState?.surface ?? 'track',
1139
+ wheels: serializeWheels(car.wheelStates),
1140
+ inPitLane: Boolean(car.trackState?.inPitLane),
1141
+ pitLanePart: car.trackState?.pitLanePart ?? null,
1142
+ pitBoxId: car.trackState?.pitBoxId ?? null,
1143
+ pitLaneCrossTrackError: car.trackState?.pitLaneCrossTrackError ?? null,
568
1144
  contactCooldown: car.contactCooldown,
569
1145
  tireEnergy: car.tireEnergy,
1146
+ usedTireCompounds: [...(car.usedTireCompounds ?? [])],
1147
+ pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
1148
+ pitStop: serializePitStop(car.pitStop),
570
1149
  positionSource: 'integrated-vehicle',
571
1150
  };
572
1151
  }
573
1152
 
1153
+ function serializeRenderPitStop(pitStop) {
1154
+ if (!pitStop) return null;
1155
+ return {
1156
+ phase: pitStop.phase ?? null,
1157
+ serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
1158
+ penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
1159
+ };
1160
+ }
1161
+
1162
+ function serializeRenderCar(car) {
1163
+ return {
1164
+ id: car.id,
1165
+ color: car.color,
1166
+ previousX: car.previousX ?? car.x,
1167
+ previousY: car.previousY ?? car.y,
1168
+ x: car.x,
1169
+ y: car.y,
1170
+ previousHeading: car.previousHeading ?? car.heading,
1171
+ heading: car.heading,
1172
+ drsActive: Boolean(car.drsActive),
1173
+ pitStop: serializeRenderPitStop(car.pitStop),
1174
+ };
1175
+ }
1176
+
1177
+ function serializeObservationPitStop(pitStop) {
1178
+ if (!pitStop) return null;
1179
+ return {
1180
+ status: pitStop.status,
1181
+ intent: normalizePitIntent(pitStop.intent) ?? PIT_INTENT_NONE,
1182
+ phase: pitStop.phase ?? null,
1183
+ targetTire: pitStop.targetTire ?? null,
1184
+ serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
1185
+ penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
1186
+ stopsCompleted: pitStop.stopsCompleted ?? 0,
1187
+ };
1188
+ }
1189
+
1190
+ function serializeObservationCar(car, rank) {
1191
+ return {
1192
+ id: car.id,
1193
+ rank,
1194
+ previousX: car.previousX ?? car.x,
1195
+ previousY: car.previousY ?? car.y,
1196
+ x: car.x,
1197
+ y: car.y,
1198
+ previousHeading: car.previousHeading ?? car.heading,
1199
+ heading: car.heading,
1200
+ steeringAngle: car.steeringAngle,
1201
+ yawRate: car.yawRate,
1202
+ speed: car.speed,
1203
+ speedKph: simSpeedToKph(car.speed),
1204
+ throttle: car.throttle,
1205
+ brake: car.brake,
1206
+ progress: car.progress,
1207
+ raceDistance: car.raceDistance,
1208
+ lap: car.lap,
1209
+ lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
1210
+ signedOffset: car.trackState?.signedOffset ?? 0,
1211
+ crossTrackError: car.trackState?.crossTrackError ?? 0,
1212
+ surface: car.trackState?.surface ?? 'track',
1213
+ trackHeadingError: car.trackHeadingError,
1214
+ trackState: car.trackState ? {
1215
+ distance: car.trackState.distance,
1216
+ signedOffset: car.trackState.signedOffset,
1217
+ crossTrackError: car.trackState.crossTrackError,
1218
+ surface: car.trackState.surface,
1219
+ inPitLane: Boolean(car.trackState.inPitLane),
1220
+ pitLanePart: car.trackState.pitLanePart ?? null,
1221
+ pitBoxId: car.trackState.pitBoxId ?? null,
1222
+ curvature: car.trackState.curvature ?? 0,
1223
+ } : null,
1224
+ wheels: serializeWheels(car.wheelStates),
1225
+ inPitLane: Boolean(car.trackState?.inPitLane),
1226
+ pitLanePart: car.trackState?.pitLanePart ?? null,
1227
+ pitBoxId: car.trackState?.pitBoxId ?? null,
1228
+ tireEnergy: car.tireEnergy ?? null,
1229
+ pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
1230
+ pitStop: serializeObservationPitStop(car.pitStop),
1231
+ };
1232
+ }
1233
+
1234
+ function createEmptyPenaltyStats() {
1235
+ return {
1236
+ seconds: 0,
1237
+ positionDrop: 0,
1238
+ disqualified: false,
1239
+ };
1240
+ }
1241
+
1242
+ function getPenaltyStats(statsByDriver, driverId) {
1243
+ return statsByDriver.get(driverId) ?? createEmptyPenaltyStats();
1244
+ }
1245
+
1246
+ function buildPenaltyStatsByDriver(penalties = []) {
1247
+ const byDriver = new Map();
1248
+ penalties.forEach((penalty) => {
1249
+ if (!penalty?.driverId || !isPenaltyActive(penalty)) return;
1250
+ const stats = byDriver.get(penalty.driverId) ?? createEmptyPenaltyStats();
1251
+ stats.seconds += Number(penalty.penaltySeconds) || 0;
1252
+ stats.positionDrop += Number(penalty.positionDrop) || 0;
1253
+ stats.disqualified = stats.disqualified || Boolean(penalty.disqualified);
1254
+ byDriver.set(penalty.driverId, stats);
1255
+ });
1256
+ return byDriver;
1257
+ }
1258
+
1259
+ function isLegallyInsidePitLaneForTrackLimits(car) {
1260
+ if (!car.trackState?.inPitLane) return false;
1261
+ const wheels = car.wheelStates ?? [];
1262
+ return wheels.length > 0 && wheels.some((wheel) => wheel.inPitLane);
1263
+ }
1264
+
574
1265
  export class F1RaceSimulation {
575
1266
  constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
576
1267
  this.seed = seed;
577
1268
  this.random = createMulberry32(seed);
578
1269
  const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
579
- this.track = buildTrackModel(trackDefinition);
1270
+ const builtTrack = buildTrackModel(trackDefinition);
1271
+ this.track = {
1272
+ ...builtTrack,
1273
+ pitLane: clonePitLaneModel(builtTrack.pitLane),
1274
+ };
1275
+ this.track.timingLines = createTimingLines(this.track);
580
1276
  this.trackSeed = this.track.seed ?? trackSeed;
581
- this.rules = { ...DEFAULT_RULES, ...rules };
1277
+ this.rules = normalizeRaceRules(rules);
582
1278
  this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
583
1279
  this.totalLaps = normalizeTotalLaps(totalLaps);
584
1280
  this.time = 0;
585
1281
  this.events = [];
1282
+ this.penalties = [];
1283
+ this.nextPenaltyId = 1;
1284
+ this.stewardState = {
1285
+ trackLimits: Object.create(null),
1286
+ pitLaneSpeeding: Object.create(null),
1287
+ tireRequirement: Object.create(null),
1288
+ };
586
1289
  this.raceControl = {
587
1290
  mode: this.rules.standingStart === false ? 'green' : 'pre-start',
588
1291
  frozenOrder: null,
1292
+ redFlag: false,
1293
+ pitLaneOpen: true,
589
1294
  finished: false,
590
1295
  finishedAt: null,
591
1296
  winnerId: null,
@@ -614,15 +1319,118 @@ export class F1RaceSimulation {
614
1319
  this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
615
1320
  standingStart: this.raceControl.mode === 'pre-start',
616
1321
  }));
1322
+ this.assignPitLaneTeams();
1323
+ this.initializePitStops();
617
1324
  this.recalculateRaceState({ updateDrs: false });
618
1325
  this.cars.forEach((car) => resetTimingHistory(car, this.time));
1326
+ this.cars.forEach((car) => resetTimingLineCrossings(car, this.time));
619
1327
  this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
620
1328
  }
621
1329
 
1330
+ assignPitLaneTeams() {
1331
+ const pitLane = this.track.pitLane;
1332
+ if (!pitLane?.enabled || !Array.isArray(pitLane.boxes)) return;
1333
+ const boxesPerTeam = pitLane.boxesPerTeam ?? 2;
1334
+ const teamCount = pitLane.teamCount ?? Math.ceil(pitLane.boxes.length / boxesPerTeam);
1335
+
1336
+ pitLane.teams = Array.from({ length: teamCount }, (_, teamIndex) => {
1337
+ const primary = this.cars[teamIndex * boxesPerTeam] ?? this.cars[teamIndex] ?? null;
1338
+ const secondary = this.cars[teamIndex * boxesPerTeam + 1] ?? null;
1339
+ const team = primary?.team ?? secondary?.team ?? null;
1340
+ const id = team?.id ?? `team-${teamIndex + 1}`;
1341
+ const name = team?.name ?? (primary ? `${primary.name} Team` : `Team ${teamIndex + 1}`);
1342
+ const color = team?.color ?? primary?.color ?? secondary?.color ?? '#f8fafc';
1343
+ const pitCrew = normalizePitCrewStats(
1344
+ team?.pitCrew ?? team?.pitCrewStats ?? primary?.team?.pitCrew ?? secondary?.team?.pitCrew,
1345
+ );
1346
+ const boxIds = pitLane.boxes
1347
+ .filter((box) => box.teamIndex === teamIndex)
1348
+ .map((box) => box.id);
1349
+
1350
+ pitLane.boxes
1351
+ .filter((box) => box.teamIndex === teamIndex)
1352
+ .forEach((box) => {
1353
+ box.teamId = id;
1354
+ box.teamName = name;
1355
+ box.teamColor = color;
1356
+ });
1357
+ const serviceArea = pitLane.serviceAreas?.[teamIndex] ?? null;
1358
+ if (serviceArea) {
1359
+ serviceArea.teamId = id;
1360
+ serviceArea.teamName = name;
1361
+ serviceArea.teamColor = color;
1362
+ serviceArea.pitCrew = pitCrew;
1363
+ }
1364
+
1365
+ return {
1366
+ id,
1367
+ name,
1368
+ color,
1369
+ index: teamIndex,
1370
+ boxIds,
1371
+ serviceAreaId: serviceArea?.id ?? null,
1372
+ pitCrew,
1373
+ };
1374
+ });
1375
+ }
1376
+
1377
+ initializePitStops() {
1378
+ const pitLane = this.track.pitLane;
1379
+ const pitStops = this.rules.modules?.pitStops;
1380
+ if (!pitStops?.enabled || !pitLane?.enabled || !Array.isArray(pitLane.boxes) || this.totalLaps < 2) return;
1381
+ const boxesPerTeam = pitLane.boxesPerTeam ?? 2;
1382
+ const maxConcurrentPitLaneCars = Math.max(1, Math.floor(pitStops.maxConcurrentPitLaneCars ?? 3));
1383
+ const pitWindowLapCount = Math.max(1, Math.min(
1384
+ this.totalLaps - 1,
1385
+ Math.ceil(this.cars.length / maxConcurrentPitLaneCars),
1386
+ ));
1387
+
1388
+ this.cars.forEach((car, index) => {
1389
+ const stopLapBase = this.track.length * (1 + (index % pitWindowLapCount));
1390
+ const trainPosition = Math.floor(index / pitWindowLapCount);
1391
+ const teamIndex = Math.floor(index / boxesPerTeam);
1392
+ const teamBoxIndex = index % boxesPerTeam;
1393
+ const garageBoxIndex = Math.min(pitLane.boxes.length - 1, teamIndex * boxesPerTeam + teamBoxIndex);
1394
+ const garageBox = pitLane.boxes[garageBoxIndex] ?? pitLane.boxes[index % pitLane.boxes.length];
1395
+ const serviceArea = pitLane.serviceAreas?.[teamIndex] ??
1396
+ pitLane.serviceAreas?.[index % pitLane.serviceAreas.length] ??
1397
+ garageBox;
1398
+ car.pitStop = {
1399
+ status: 'pending',
1400
+ phase: null,
1401
+ boxIndex: serviceArea.index,
1402
+ boxId: serviceArea.id,
1403
+ garageBoxIndex,
1404
+ garageBoxId: garageBox.id,
1405
+ teamId: serviceArea.teamId ?? garageBox.teamId ?? null,
1406
+ teamColor: serviceArea.teamColor ?? garageBox.teamColor ?? null,
1407
+ stopsCompleted: 0,
1408
+ entryRaceDistance: stopLapBase + pitLane.entry.distanceFromStart,
1409
+ plannedRaceDistance: stopLapBase + pitLane.entry.distanceFromStart -
1410
+ PIT_ENTRY_APPROACH_DISTANCE,
1411
+ trainPosition,
1412
+ lapBase: stopLapBase,
1413
+ serviceRemaining: 0,
1414
+ penaltyServiceRemaining: 0,
1415
+ penaltyServiceTotal: 0,
1416
+ servingPenaltyIds: [],
1417
+ serviceProfile: null,
1418
+ queueingForService: false,
1419
+ route: null,
1420
+ routeProgress: 0,
1421
+ routeStartRaceDistance: null,
1422
+ routeEndRaceDistance: null,
1423
+ targetTire: firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds),
1424
+ intent: PIT_INTENT_NONE,
1425
+ };
1426
+ });
1427
+ }
1428
+
622
1429
  setSafetyCar(deployed) {
623
1430
  const next = Boolean(deployed);
624
1431
  if (this.raceControl.finished) return;
625
1432
  if (next && this.raceControl.mode === 'pre-start') return;
1433
+ if (this.raceControl.redFlag) return;
626
1434
  if (next === this.safetyCar.deployed) return;
627
1435
  const ordered = this.orderedCars();
628
1436
  this.safetyCar.deployed = next;
@@ -645,9 +1453,53 @@ export class F1RaceSimulation {
645
1453
  this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
646
1454
  }
647
1455
 
1456
+ setRedFlag(deployed) {
1457
+ const next = Boolean(deployed);
1458
+ if (this.raceControl.finished) return;
1459
+ if (next && this.raceControl.mode === 'pre-start') return;
1460
+ if (next === Boolean(this.raceControl.redFlag)) return;
1461
+ this.raceControl.redFlag = next;
1462
+ if (next) {
1463
+ this.safetyCar.deployed = false;
1464
+ this.raceControl.mode = 'red-flag';
1465
+ this.raceControl.frozenOrder = this.orderedCars().map((car) => car.id);
1466
+ this.cars.forEach((car) => {
1467
+ car.speed = 0;
1468
+ car.throttle = 0;
1469
+ car.brake = 1;
1470
+ car.drsActive = false;
1471
+ car.drsEligible = false;
1472
+ car.drsZoneId = null;
1473
+ car.drsZoneEnabled = false;
1474
+ car.canAttack = false;
1475
+ });
1476
+ } else {
1477
+ this.raceControl.mode = this.safetyCar.deployed ? 'safety-car' : 'green';
1478
+ this.raceControl.frozenOrder = this.safetyCar.deployed
1479
+ ? this.orderedCars().map((car) => car.id)
1480
+ : null;
1481
+ this.cars.forEach((car) => {
1482
+ if (!car.finished) {
1483
+ car.speed = Math.max(car.speed, kphToSimSpeed(60));
1484
+ car.brake = 0;
1485
+ car.throttle = Math.max(car.throttle ?? 0, 0.35);
1486
+ }
1487
+ });
1488
+ }
1489
+ this.events.unshift({ type: next ? 'red-flag' : 'green-flag', at: this.time });
1490
+ }
1491
+
1492
+ setPitLaneOpen(open) {
1493
+ const next = Boolean(open);
1494
+ if (next === Boolean(this.raceControl.pitLaneOpen)) return;
1495
+ this.raceControl.pitLaneOpen = next;
1496
+ this.events.unshift({ type: next ? 'pit-lane-open' : 'pit-lane-closed', at: this.time });
1497
+ }
1498
+
648
1499
  setCarState(id, partial) {
649
1500
  const car = this.cars.find((item) => item.id === id);
650
1501
  if (!car) return;
1502
+ const hasExplicitRaceDistance = partial.raceDistance != null;
651
1503
  Object.assign(car, partial);
652
1504
  if (
653
1505
  partial.x != null ||
@@ -662,9 +1514,21 @@ export class F1RaceSimulation {
662
1514
  }
663
1515
  car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
664
1516
  car.heading = normalizeAngle(car.heading);
665
- car.trackState = nearestTrackState(this.track, car);
666
- const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
667
- car.raceDistance = (car.raceDistance ?? car.trackState.distance) + delta;
1517
+ const centerState = nearestTrackState(this.track, car, car.progress ?? car.raceDistance);
1518
+ applyWheelSurfaceState(car, this.track, { centerState });
1519
+ if (
1520
+ partial.desiredOffset == null &&
1521
+ (partial.x != null || partial.y != null || partial.progress != null || partial.raceDistance != null)
1522
+ ) {
1523
+ car.desiredOffset = centerState.signedOffset ?? 0;
1524
+ }
1525
+ if ((partial.x != null || partial.y != null) && car.pitStop?.route) {
1526
+ car.pitStop.routeProgress = nearestDistanceOnRoute(car.pitStop.route, car, car.pitStop.routeProgress ?? 0);
1527
+ }
1528
+ if (!hasExplicitRaceDistance) {
1529
+ const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
1530
+ car.raceDistance = (car.raceDistance ?? car.trackState.distance) + delta;
1531
+ }
668
1532
  car.progress = car.trackState.distance;
669
1533
  car.lap = this.computeLap(car.raceDistance);
670
1534
  car.drsZoneId = null;
@@ -673,6 +1537,7 @@ export class F1RaceSimulation {
673
1537
  car.drsEligible = false;
674
1538
  car.drsDetection = {};
675
1539
  resetTimingHistory(car, this.time);
1540
+ resetTimingLineCrossings(car, this.time);
676
1541
  resetLapTelemetry(car, this.time, this.track, this.totalLaps);
677
1542
  this.recalculateRaceState({ updateDrs: false });
678
1543
  this.evaluateRaceFinish();
@@ -689,8 +1554,712 @@ export class F1RaceSimulation {
689
1554
  if (car) car.manualControls = null;
690
1555
  }
691
1556
 
692
- step(dt) {
693
- const delta = clamp(dt, 0, 1 / 20);
1557
+ setAutomaticPitIntentEnabled(id, enabled) {
1558
+ const car = this.cars.find((item) => item.id === id);
1559
+ if (!car) return false;
1560
+ car.automaticPitIntentEnabled = Boolean(enabled);
1561
+ return true;
1562
+ }
1563
+
1564
+ getPitIntent(id) {
1565
+ const car = this.cars.find((item) => item.id === id);
1566
+ return normalizePitIntent(car?.pitStop?.intent) ?? PIT_INTENT_NONE;
1567
+ }
1568
+
1569
+ getPitTargetCompound(id) {
1570
+ const car = this.cars.find((item) => item.id === id);
1571
+ return car?.pitStop?.targetTire ?? null;
1572
+ }
1573
+
1574
+ setPitIntent(id, intent, targetCompound = undefined) {
1575
+ const request = intent && typeof intent === 'object'
1576
+ ? intent
1577
+ : { intent, targetCompound };
1578
+ const nextIntent = normalizePitIntent(request.intent ?? request.pitIntent);
1579
+ if (nextIntent == null) return false;
1580
+ const car = this.cars.find((item) => item.id === id);
1581
+ const stop = car?.pitStop;
1582
+ const pitStops = this.rules.modules?.pitStops;
1583
+ if (!car || !stop || !pitStops?.enabled || !this.track.pitLane?.enabled) return false;
1584
+ if (this.isCarInActivePitStop(car)) return false;
1585
+
1586
+ const compoundRequest = request.targetCompound ??
1587
+ request.compound ??
1588
+ request.pitCompound ??
1589
+ request.pitTargetCompound ??
1590
+ request.targetTire;
1591
+ const hasCompoundRequest = compoundRequest != null && compoundRequest !== '';
1592
+ if (hasCompoundRequest) {
1593
+ const targetTire = normalizePitCompound(compoundRequest, this.rules.modules?.tireStrategy?.compounds);
1594
+ if (!targetTire) return false;
1595
+ stop.targetTire = targetTire;
1596
+ } else if (nextIntent !== PIT_INTENT_NONE && (!stop.targetTire || stop.status === 'completed')) {
1597
+ stop.targetTire = firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
1598
+ } else if (nextIntent === PIT_INTENT_NONE) {
1599
+ stop.targetTire = firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
1600
+ }
1601
+
1602
+ stop.intent = nextIntent;
1603
+ if (stop.status === 'pending' && nextIntent !== PIT_INTENT_NONE) {
1604
+ this.schedulePitStopAtNextEntry(car, stop);
1605
+ } else if (stop.status === 'completed' && nextIntent !== PIT_INTENT_NONE) {
1606
+ this.rearmCompletedPitStop(car, stop);
1607
+ }
1608
+ return true;
1609
+ }
1610
+
1611
+ isCarInActivePitStop(car) {
1612
+ return Boolean(car.pitStop && car.pitStop.status !== 'pending' && car.pitStop.status !== 'completed');
1613
+ }
1614
+
1615
+ schedulePitStopAtNextEntry(car, stop) {
1616
+ const pitLane = this.track.pitLane;
1617
+ if (!stop || !pitLane?.entry || !Number.isFinite(this.track.length) || this.track.length <= 0) return false;
1618
+ const raceDistance = car.raceDistance ?? 0;
1619
+ const entryOffset = pitLane.entry.distanceFromStart ?? 0;
1620
+ let lapBase = Math.floor((raceDistance - entryOffset) / this.track.length) * this.track.length;
1621
+ let entryRaceDistance = lapBase + entryOffset;
1622
+
1623
+ while (raceDistance > entryRaceDistance + metersToSimUnits(90)) {
1624
+ lapBase += this.track.length;
1625
+ entryRaceDistance = lapBase + entryOffset;
1626
+ }
1627
+
1628
+ stop.lapBase = lapBase;
1629
+ stop.entryRaceDistance = entryRaceDistance;
1630
+ stop.plannedRaceDistance = entryRaceDistance - PIT_ENTRY_APPROACH_DISTANCE;
1631
+ return true;
1632
+ }
1633
+
1634
+ rearmCompletedPitStop(car, stop) {
1635
+ if (!stop || stop.status !== 'completed') return false;
1636
+ stop.status = 'pending';
1637
+ stop.phase = null;
1638
+ stop.route = null;
1639
+ stop.routeProgress = 0;
1640
+ stop.routeStartRaceDistance = null;
1641
+ stop.routeEndRaceDistance = null;
1642
+ stop.serviceRemaining = 0;
1643
+ stop.penaltyServiceRemaining = 0;
1644
+ stop.penaltyServiceTotal = 0;
1645
+ stop.servingPenaltyIds = [];
1646
+ stop.queueingForService = false;
1647
+ stop.serviceProfile = null;
1648
+ stop.targetTire = stop.targetTire ?? firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
1649
+ return this.schedulePitStopAtNextEntry(car, stop);
1650
+ }
1651
+
1652
+ canStartPitStop(car) {
1653
+ const stop = car.pitStop;
1654
+ const pitStops = this.rules.modules?.pitStops;
1655
+ if (!stop || !pitStops?.enabled) return false;
1656
+ const active = this.cars.filter((candidate) => candidate !== car && this.isCarInActivePitStop(candidate));
1657
+ const maxConcurrentPitLaneCars = Math.max(1, Math.floor(pitStops.maxConcurrentPitLaneCars ?? 3));
1658
+ if (active.length >= maxConcurrentPitLaneCars) return false;
1659
+
1660
+ if (!pitStops.doubleStacking && stop.teamId) {
1661
+ const box = this.getPitStopBox(stop);
1662
+ if (this.isPitServiceQueueOccupied(car, box)) return false;
1663
+ }
1664
+
1665
+ const minimumGap = Math.max(0, pitStops.minimumPitLaneGap ?? 0);
1666
+ const candidateDistance = car.raceDistance ?? 0;
1667
+ return active.every((candidate) => {
1668
+ const gap = (candidate.raceDistance ?? 0) - candidateDistance;
1669
+ return gap < 0 || gap >= minimumGap;
1670
+ });
1671
+ }
1672
+
1673
+ shouldStartPitStop(car) {
1674
+ const stop = car.pitStop;
1675
+ if (!stop || stop.status !== 'pending' || car.finished) return false;
1676
+ if (!pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops).open) return false;
1677
+ const intent = normalizePitIntent(stop.intent) ?? PIT_INTENT_NONE;
1678
+ if (intent === PIT_INTENT_NONE) return false;
1679
+ const raceDistance = car.raceDistance ?? 0;
1680
+ if (raceDistance > stop.entryRaceDistance + metersToSimUnits(90)) {
1681
+ this.schedulePitStopAtNextEntry(car, stop);
1682
+ return false;
1683
+ }
1684
+ return raceDistance >= stop.plannedRaceDistance &&
1685
+ (intent === PIT_INTENT_COMMITTED || this.canStartPitStop(car));
1686
+ }
1687
+
1688
+ updateAutomaticPitIntent(car) {
1689
+ const stop = car.pitStop;
1690
+ const pitStops = this.rules.modules?.pitStops;
1691
+ if (!stop || !['pending', 'completed'].includes(stop.status) || !pitStops?.enabled || car.finished) return;
1692
+ if (car.automaticPitIntentEnabled === false) return;
1693
+ const tireEnergy = Number(car.tireEnergy ?? 100);
1694
+ const requestThreshold = Number(pitStops.tirePitRequestThresholdPercent ?? 50);
1695
+ const commitThreshold = Number(pitStops.tirePitCommitThresholdPercent ?? 30);
1696
+ if (!Number.isFinite(tireEnergy)) return;
1697
+
1698
+ let nextIntent = PIT_INTENT_NONE;
1699
+ if (tireEnergy < commitThreshold) nextIntent = PIT_INTENT_COMMITTED;
1700
+ else if (tireEnergy < requestThreshold) nextIntent = PIT_INTENT_IF_FREE;
1701
+ if (nextIntent === PIT_INTENT_NONE || nextIntent <= (normalizePitIntent(stop.intent) ?? PIT_INTENT_NONE)) return;
1702
+
1703
+ stop.intent = nextIntent;
1704
+ if (stop.status === 'completed') this.rearmCompletedPitStop(car, stop);
1705
+ else this.schedulePitStopAtNextEntry(car, stop);
1706
+ }
1707
+
1708
+ getPitStopBox(stop) {
1709
+ return this.track.pitLane?.serviceAreas?.find((box) => box.id === stop?.boxId) ??
1710
+ this.track.pitLane?.boxes?.find((box) => box.id === stop?.boxId) ??
1711
+ null;
1712
+ }
1713
+
1714
+ isPitServiceBusy(car, box) {
1715
+ return this.cars.some((candidate) => (
1716
+ candidate !== car &&
1717
+ candidate.pitStop?.boxId === box?.id &&
1718
+ this.isPitServiceAreaOccupied(candidate, box)
1719
+ ));
1720
+ }
1721
+
1722
+ isPitServiceAreaOccupied(candidate, box) {
1723
+ const status = candidate?.pitStop?.status;
1724
+ const phase = candidate?.pitStop?.phase;
1725
+ if (!status || !box) return false;
1726
+ if (status === 'servicing') return true;
1727
+ if (status === 'entering' && phase === 'queue-release') return true;
1728
+ if (status === 'exiting') return pointDistance(candidate, box.center) < PIT_SERVICE_CLEAR_DISTANCE;
1729
+ return false;
1730
+ }
1731
+
1732
+ isPitServiceQueueOccupied(car, box) {
1733
+ return this.cars.some((candidate) => (
1734
+ candidate !== car &&
1735
+ candidate.pitStop?.boxId === box?.id &&
1736
+ (
1737
+ candidate.pitStop?.status === 'queued' ||
1738
+ Boolean(candidate.pitStop?.queueingForService)
1739
+ )
1740
+ ));
1741
+ }
1742
+
1743
+ getPitBoxRaceDistance(stop, box) {
1744
+ const pitLane = this.track.pitLane;
1745
+ const amount = pitLane?.mainLane?.length > 0 ? box.distanceAlongLane / pitLane.mainLane.length : 0;
1746
+ return stop.lapBase + pitLane.entry.distanceFromStart +
1747
+ (pitLane.exit.distanceFromStart - pitLane.entry.distanceFromStart) * amount;
1748
+ }
1749
+
1750
+ startPitStop(car) {
1751
+ const stop = car.pitStop;
1752
+ const box = this.getPitStopBox(stop);
1753
+ const pitLane = this.track.pitLane;
1754
+ if (!stop || !box || !pitLane?.enabled) return false;
1755
+
1756
+ car.gridLocked = false;
1757
+ car.drsActive = false;
1758
+ car.drsEligible = false;
1759
+ car.drsZoneId = null;
1760
+ car.drsZoneEnabled = false;
1761
+ car.canAttack = false;
1762
+ stop.targetTire = stop.targetTire ?? firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
1763
+
1764
+ const currentState = applyWheelSurfaceState(car, this.track).representativeState;
1765
+ this.events.unshift({
1766
+ type: 'pit-entry',
1767
+ at: this.time,
1768
+ carId: car.id,
1769
+ boxId: box.id,
1770
+ teamId: box.teamId ?? null,
1771
+ });
1772
+
1773
+ if (currentState.surface === 'pit-box' && currentState.pitBoxId === box.id) {
1774
+ this.beginPitService(car, box);
1775
+ return true;
1776
+ }
1777
+
1778
+ const hasServiceQueue = Boolean(box.queuePoint);
1779
+ const shouldStageForService = hasServiceQueue;
1780
+ const queueDistanceAlongLane = box.queueDistanceAlongLane ??
1781
+ Math.max(0, box.distanceAlongLane - PIT_SERVICE_QUEUE_FALLBACK_GAP);
1782
+ const stopDistanceAlongLane = shouldStageForService
1783
+ ? queueDistanceAlongLane
1784
+ : box.distanceAlongLane;
1785
+ const serviceTarget = shouldStageForService ? box.queuePoint : box.center;
1786
+ const driveLaneOffset = pitDriveLaneOffset(pitLane);
1787
+ const boxApproachDistance = Math.max(
1788
+ 0,
1789
+ (hasServiceQueue ? queueDistanceAlongLane : stopDistanceAlongLane) - PIT_BOX_APPROACH_DISTANCE,
1790
+ );
1791
+ const route = createRoute([
1792
+ ...createPitApproachPoints(this.track, car, pitLane, stop.entryRaceDistance),
1793
+ ...(pitLane.entry.roadCenterline ?? []).map((point) => routePoint(point)),
1794
+ routePoint(pitMainLanePointAt(pitLane, 0, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
1795
+ routePoint(pitMainLanePointAt(pitLane, boxApproachDistance, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
1796
+ routePoint(serviceTarget, pitLane.mainLane.heading, { limiterActive: true }),
1797
+ ]);
1798
+ stop.status = 'entering';
1799
+ stop.phase = 'entry';
1800
+ stop.queueingForService = shouldStageForService;
1801
+ stop.route = route;
1802
+ stop.routeProgress = 0;
1803
+ stop.routeStartRaceDistance = car.raceDistance ?? stop.plannedRaceDistance;
1804
+ stop.routeEndRaceDistance = this.getPitBoxRaceDistance(stop, {
1805
+ ...box,
1806
+ distanceAlongLane: stopDistanceAlongLane,
1807
+ });
1808
+ return true;
1809
+ }
1810
+
1811
+ beginPitQueue(car, box) {
1812
+ const stop = car.pitStop;
1813
+ if (!stop || !box?.queuePoint) return false;
1814
+ stop.status = 'queued';
1815
+ stop.phase = 'queue';
1816
+ stop.route = null;
1817
+ stop.routeProgress = 0;
1818
+ stop.queueingForService = false;
1819
+ car.x = box.queuePoint.x;
1820
+ car.y = box.queuePoint.y;
1821
+ car.heading = this.track.pitLane.mainLane.heading;
1822
+ car.speed = 0;
1823
+ car.throttle = 0;
1824
+ car.brake = 1;
1825
+ applyWheelSurfaceState(car, this.track);
1826
+ car.progress = car.trackState.distance;
1827
+ car.raceDistance = this.getPitBoxRaceDistance(stop, {
1828
+ ...box,
1829
+ distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
1830
+ });
1831
+ return true;
1832
+ }
1833
+
1834
+ releasePitQueue(car, box, { fromCurrent = false } = {}) {
1835
+ const stop = car.pitStop;
1836
+ if (!stop || !box?.center) return false;
1837
+ const route = createRoute([
1838
+ ...(fromCurrent ? [routePoint(car, car.heading, { limiterActive: true })] : []),
1839
+ routePoint(box.queuePoint, this.track.pitLane.mainLane.heading, { limiterActive: true }),
1840
+ routePoint(box.center, this.track.pitLane.mainLane.heading, { limiterActive: true }),
1841
+ ]);
1842
+ stop.status = 'entering';
1843
+ stop.phase = 'queue-release';
1844
+ stop.queueingForService = false;
1845
+ stop.route = route;
1846
+ stop.routeProgress = 0;
1847
+ stop.routeStartRaceDistance = fromCurrent
1848
+ ? car.raceDistance ?? this.getPitBoxRaceDistance(stop, {
1849
+ ...box,
1850
+ distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
1851
+ })
1852
+ : this.getPitBoxRaceDistance(stop, {
1853
+ ...box,
1854
+ distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
1855
+ });
1856
+ stop.routeEndRaceDistance = this.getPitBoxRaceDistance(stop, box);
1857
+ return true;
1858
+ }
1859
+
1860
+ beginPitService(car, box) {
1861
+ const stop = car.pitStop;
1862
+ stop.status = 'servicing';
1863
+ stop.route = null;
1864
+ stop.routeProgress = 0;
1865
+ car.x = box.center.x;
1866
+ car.y = box.center.y;
1867
+ car.heading = this.track.pitLane.mainLane.heading;
1868
+ car.speed = 0;
1869
+ car.throttle = 0;
1870
+ car.brake = 1;
1871
+ car.steeringAngle = 0;
1872
+ car.yawRate = 0;
1873
+ car.turnRadius = Infinity;
1874
+ applyWheelSurfaceState(car, this.track);
1875
+ car.progress = car.trackState.distance;
1876
+ car.raceDistance = this.getPitBoxRaceDistance(stop, box);
1877
+
1878
+ if (this.beginPitPenaltyService(car)) return;
1879
+ this.beginTireService(car);
1880
+ }
1881
+
1882
+ beginPitPenaltyService(car) {
1883
+ const stop = car.pitStop;
1884
+ const penalties = this.getPitServicePenalties(car.id);
1885
+ if (!stop || !penalties.length) return false;
1886
+
1887
+ const totalSeconds = penalties.reduce((total, penalty) => (
1888
+ total + getPitServicePenaltySeconds(penalty)
1889
+ ), 0);
1890
+ stop.servingPenaltyIds = penalties.map((penalty) => penalty.id);
1891
+ stop.penaltyServiceTotal = totalSeconds;
1892
+ stop.penaltyServiceRemaining = totalSeconds;
1893
+
1894
+ if (totalSeconds <= 0) {
1895
+ this.completePitPenaltyService(car);
1896
+ return true;
1897
+ }
1898
+
1899
+ stop.phase = 'penalty';
1900
+ stop.serviceRemaining = totalSeconds;
1901
+ this.events.unshift({
1902
+ type: 'pit-penalty-service-start',
1903
+ at: this.time,
1904
+ carId: car.id,
1905
+ penaltyIds: [...stop.servingPenaltyIds],
1906
+ penaltyServiceSeconds: totalSeconds,
1907
+ });
1908
+ return true;
1909
+ }
1910
+
1911
+ calculatePitServiceProfile(car) {
1912
+ const pitStops = this.rules.modules?.pitStops ?? {};
1913
+ const variability = pitStops.variability ?? {};
1914
+ const baseSeconds = Math.max(0, Number(pitStops.defaultStopSeconds) || 2.8);
1915
+ const box = this.getPitStopBox(car?.pitStop);
1916
+ const team = this.track.pitLane?.teams?.find((entry) => entry.id === car?.pitStop?.teamId);
1917
+ const pitCrew = normalizePitCrewStats(
1918
+ box?.pitCrew ?? team?.pitCrew ?? car?.team?.pitCrew ?? car?.team?.pitCrewStats,
1919
+ );
1920
+ const profile = {
1921
+ baseSeconds,
1922
+ seconds: baseSeconds,
1923
+ perfect: Boolean(variability.perfect),
1924
+ variabilityEnabled: Boolean(variability.enabled),
1925
+ teamId: car?.pitStop?.teamId ?? box?.teamId ?? null,
1926
+ pitCrew,
1927
+ speedDeltaSeconds: 0,
1928
+ consistencyDeltaSeconds: 0,
1929
+ issueDelaySeconds: 0,
1930
+ issue: null,
1931
+ };
1932
+
1933
+ if (!variability.enabled || variability.perfect) return profile;
1934
+
1935
+ const speedImpact = Math.max(0, Number(variability.speedImpactSeconds) || 0);
1936
+ const jitterImpact = Math.max(0, Number(variability.consistencyJitterSeconds) || 0);
1937
+ const issueChance = clamp(Number(variability.issueChance) || 0, 0, 1);
1938
+ const issueMaxDelay = Math.max(0, Number(variability.issueMaxDelaySeconds) || 0);
1939
+ profile.speedDeltaSeconds = (0.5 - pitCrew.speed) * speedImpact;
1940
+ profile.consistencyDeltaSeconds = (this.random() - 0.5) * (1 - pitCrew.consistency) * jitterImpact;
1941
+ const effectiveIssueChance = issueChance * (1 - pitCrew.reliability);
1942
+ if (this.random() < effectiveIssueChance) {
1943
+ profile.issueDelaySeconds = 0.35 + this.random() * issueMaxDelay * (1 - pitCrew.reliability);
1944
+ profile.issue = 'slow-stop';
1945
+ }
1946
+ profile.seconds = Math.max(
1947
+ Math.min(1.6, baseSeconds),
1948
+ baseSeconds + profile.speedDeltaSeconds + profile.consistencyDeltaSeconds + profile.issueDelaySeconds,
1949
+ );
1950
+ return profile;
1951
+ }
1952
+
1953
+ beginTireService(car) {
1954
+ const stop = car.pitStop;
1955
+ if (!stop) return;
1956
+ const serviceProfile = this.calculatePitServiceProfile(car);
1957
+ stop.phase = 'service';
1958
+ stop.penaltyServiceRemaining = 0;
1959
+ stop.serviceProfile = serviceProfile;
1960
+ stop.serviceRemaining = serviceProfile.seconds;
1961
+ this.events.unshift({
1962
+ type: 'pit-stop-start',
1963
+ at: this.time,
1964
+ carId: car.id,
1965
+ boxId: stop.boxId,
1966
+ teamId: stop.teamId ?? null,
1967
+ targetTire: stop.targetTire,
1968
+ serviceSeconds: serviceProfile.seconds,
1969
+ serviceIssue: serviceProfile.issue,
1970
+ });
1971
+ }
1972
+
1973
+ completePitPenaltyService(car) {
1974
+ const stop = car.pitStop;
1975
+ if (!stop) return;
1976
+ const servedIds = [...(stop.servingPenaltyIds ?? [])];
1977
+ servedIds.forEach((penaltyId) => {
1978
+ const penalty = this.penalties.find((entry) => entry.id === penaltyId);
1979
+ const beforeStatus = penalty?.status;
1980
+ const result = servePitPenaltyRecord(penalty, this.time);
1981
+ if (result && beforeStatus !== result.status) {
1982
+ this.events.unshift({
1983
+ type: 'penalty-served',
1984
+ at: this.time,
1985
+ penaltyId: result.id,
1986
+ driverId: result.driverId,
1987
+ serviceType: result.serviceType,
1988
+ serviceContext: 'pit-stop',
1989
+ });
1990
+ }
1991
+ });
1992
+ stop.penaltyServiceRemaining = 0;
1993
+ stop.serviceRemaining = 0;
1994
+ this.events.unshift({
1995
+ type: 'pit-penalty-service-complete',
1996
+ at: this.time,
1997
+ carId: car.id,
1998
+ penaltyIds: servedIds,
1999
+ });
2000
+ this.beginTireService(car);
2001
+ }
2002
+
2003
+ getPitServicePenalties(driverId) {
2004
+ return this.penalties.filter((penalty) => (
2005
+ penalty.driverId === driverId && isPenaltyPitServiceable(penalty)
2006
+ ));
2007
+ }
2008
+
2009
+ completePitService(car) {
2010
+ const stop = car.pitStop;
2011
+ const box = this.getPitStopBox(stop);
2012
+ const pitLane = this.track.pitLane;
2013
+ if (!stop || !box || !pitLane?.enabled) return;
2014
+
2015
+ car.tire = stop.targetTire ?? car.tire;
2016
+ car.tireEnergy = 100;
2017
+ if (car.tire && !car.usedTireCompounds.includes(car.tire)) car.usedTireCompounds.push(car.tire);
2018
+ this.events.unshift({
2019
+ type: 'pit-stop-complete',
2020
+ at: this.time,
2021
+ carId: car.id,
2022
+ boxId: box.id,
2023
+ teamId: box.teamId ?? null,
2024
+ tire: car.tire,
2025
+ });
2026
+
2027
+ const driveLaneOffset = pitDriveLaneOffset(pitLane);
2028
+ const boxReleaseDistance = Math.min(pitLane.mainLane.length, box.distanceAlongLane + PIT_BOX_APPROACH_DISTANCE);
2029
+ const route = createRoute([
2030
+ routePoint(box.center, pitLane.mainLane.heading, { limiterActive: true }),
2031
+ routePoint(box.laneTarget, pitLane.mainLane.heading, { limiterActive: true }),
2032
+ routePoint(pitMainLanePointAt(pitLane, boxReleaseDistance, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
2033
+ routePoint(pitMainLanePointAt(pitLane, pitLane.mainLane.length, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
2034
+ ...(pitLane.exit.roadCenterline ?? []).map((point) => routePoint(point)),
2035
+ ]);
2036
+ stop.status = 'exiting';
2037
+ stop.phase = 'exit';
2038
+ stop.route = route;
2039
+ stop.routeProgress = 0;
2040
+ stop.routeStartRaceDistance = this.getPitBoxRaceDistance(stop, box);
2041
+ stop.routeEndRaceDistance = stop.lapBase + pitLane.exit.distanceFromStart;
2042
+ }
2043
+
2044
+ finishPitExit(car) {
2045
+ const stop = car.pitStop;
2046
+ if (!stop) return;
2047
+ stop.status = 'completed';
2048
+ stop.phase = null;
2049
+ stop.route = null;
2050
+ stop.routeProgress = 0;
2051
+ stop.serviceRemaining = 0;
2052
+ stop.queueingForService = false;
2053
+ stop.stopsCompleted = (stop.stopsCompleted ?? 0) + 1;
2054
+ stop.intent = PIT_INTENT_NONE;
2055
+ car.speed = Math.max(car.speed, kphToSimSpeed(PIT_EXIT_RELEASE_SPEED_KPH));
2056
+ car.throttle = 0.55;
2057
+ car.brake = 0;
2058
+ applyWheelSurfaceState(car, this.track);
2059
+ car.progress = car.trackState.distance;
2060
+ car.raceDistance = Math.max(car.raceDistance ?? 0, stop.routeEndRaceDistance ?? car.raceDistance ?? 0);
2061
+ this.events.unshift({
2062
+ type: 'pit-exit',
2063
+ at: this.time,
2064
+ carId: car.id,
2065
+ boxId: stop.boxId,
2066
+ teamId: stop.teamId ?? null,
2067
+ });
2068
+ }
2069
+
2070
+ applyPitRoutePosition(car, delta) {
2071
+ const stop = car.pitStop;
2072
+ if (!stop?.route) return false;
2073
+ const speedLimit = kphToSimSpeed(this.rules.modules?.pitStops?.pitLaneSpeedLimitKph ?? 80);
2074
+ const route = stop.route;
2075
+ if (stop.phase === 'queue-release') {
2076
+ const nextProgress = Math.min(route.length, (stop.routeProgress ?? 0) + PIT_QUEUE_RELEASE_SPEED * delta);
2077
+ const point = sampleRoute(route, nextProgress);
2078
+ if (!point) return false;
2079
+ const previousHeading = car.heading;
2080
+ car.previousX = car.x;
2081
+ car.previousY = car.y;
2082
+ car.previousHeading = car.heading;
2083
+ car.x = point.x;
2084
+ car.y = point.y;
2085
+ car.heading = point.heading ?? this.track.pitLane.mainLane.heading;
2086
+ car.speed = PIT_QUEUE_RELEASE_SPEED;
2087
+ car.throttle = 0.32;
2088
+ car.brake = 0;
2089
+ car.yawRate = normalizeAngle(car.heading - previousHeading) / Math.max(delta, 1e-6);
2090
+ car.steeringAngle = clamp(
2091
+ Math.atan((car.yawRate * VEHICLE_LIMITS.wheelbase) / Math.max(car.speed, 1)),
2092
+ -VEHICLE_LIMITS.maxSteer,
2093
+ VEHICLE_LIMITS.maxSteer,
2094
+ );
2095
+ car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
2096
+ stop.routeProgress = nextProgress;
2097
+ const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
2098
+ car.raceDistance = stop.routeStartRaceDistance +
2099
+ (stop.routeEndRaceDistance - stop.routeStartRaceDistance) * routeAmount;
2100
+ applyWheelSurfaceState(car, this.track);
2101
+ car.progress = car.trackState.distance;
2102
+ car.lap = this.computeLap(car.raceDistance);
2103
+ return route.length - stop.routeProgress <= PIT_QUEUE_RELEASE_FINISH_DISTANCE;
2104
+ }
2105
+ stop.routeProgress = clamp(stop.routeProgress ?? nearestDistanceOnRoute(route, car, 0), 0, route.length);
2106
+ const routeEnd = route.points.at(-1);
2107
+ const limiterActive = routeLimiterActiveAt(route, stop.routeProgress);
2108
+ let targetSpeed = limiterActive
2109
+ ? Math.min(speedLimit, VEHICLE_LIMITS.maxSpeed)
2110
+ : Math.min(kphToSimSpeed(150), VEHICLE_LIMITS.maxSpeed);
2111
+ if (!limiterActive && stop.status === 'entering') {
2112
+ targetSpeed = Math.min(targetSpeed, speedLimit + kphToSimSpeed(PIT_ENTRY_CONNECTOR_OVERSPEED_KPH));
2113
+ const distanceToLimiter = distanceToNextLimiterSegment(route, stop.routeProgress);
2114
+ if (distanceToLimiter < PIT_LIMITER_BRAKE_DISTANCE) {
2115
+ targetSpeed = Math.min(
2116
+ targetSpeed,
2117
+ speedLimit + Math.max(0, distanceToLimiter * PIT_LIMITER_APPROACH_SPEED_SLOPE),
2118
+ );
2119
+ }
2120
+ }
2121
+ if (stop.status === 'entering') {
2122
+ const remainingBefore = Math.max(0, route.length - stop.routeProgress);
2123
+ const captureDistance = stop.queueingForService ? PIT_QUEUE_CAPTURE_DISTANCE : PIT_ENTRY_BOX_CAPTURE_DISTANCE;
2124
+ const brakeZone = stop.queueingForService ? metersToSimUnits(25) : metersToSimUnits(80);
2125
+ if (remainingBefore < brakeZone) {
2126
+ const brakingSlope = stop.queueingForService ? kphToSimSpeed(62) / metersToSimUnits(100) : kphToSimSpeed(24) / metersToSimUnits(100);
2127
+ const captureSpeedFloor = stop.queueingForService ? kphToSimSpeed(42) : kphToSimSpeed(28);
2128
+ const approachSpeed = remainingBefore > captureDistance
2129
+ ? Math.max(captureSpeedFloor, remainingBefore * brakingSlope)
2130
+ : remainingBefore * brakingSlope;
2131
+ targetSpeed = Math.min(targetSpeed, clamp(approachSpeed, 0, speedLimit * 0.78));
2132
+ }
2133
+ }
2134
+
2135
+ const nextProgress = Math.min(route.length, stop.routeProgress + Math.max(0, targetSpeed) * delta);
2136
+ const point = sampleRoute(route, nextProgress);
2137
+ if (!point) return false;
2138
+ const previousSpeed = car.speed;
2139
+ const previousHeading = car.heading;
2140
+ car.previousX = car.x;
2141
+ car.previousY = car.y;
2142
+ car.previousHeading = car.heading;
2143
+ car.x = point.x;
2144
+ car.y = point.y;
2145
+ car.heading = point.heading ?? car.heading;
2146
+ car.speed = targetSpeed;
2147
+ car.throttle = limiterActive ? 0.28 : 0.46;
2148
+ car.brake = targetSpeed < previousSpeed ? 0.4 : 0;
2149
+ car.yawRate = normalizeAngle(car.heading - previousHeading) / Math.max(delta, 1e-6);
2150
+ car.steeringAngle = clamp(
2151
+ Math.atan((car.yawRate * VEHICLE_LIMITS.wheelbase) / Math.max(car.speed, 1)),
2152
+ -VEHICLE_LIMITS.maxSteer,
2153
+ VEHICLE_LIMITS.maxSteer,
2154
+ );
2155
+ car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
2156
+ applyWheelSurfaceState(car, this.track);
2157
+ stop.routeProgress = nextProgress;
2158
+ const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
2159
+ car.raceDistance = stop.routeStartRaceDistance +
2160
+ (stop.routeEndRaceDistance - stop.routeStartRaceDistance) * routeAmount;
2161
+ car.progress = car.trackState.distance;
2162
+ car.lap = this.computeLap(car.raceDistance);
2163
+ const remainingAfter = Math.max(0, route.length - stop.routeProgress);
2164
+ if (stop.status === 'entering') {
2165
+ const captureDistance = stop.queueingForService ? PIT_QUEUE_CAPTURE_DISTANCE : PIT_ENTRY_BOX_CAPTURE_DISTANCE;
2166
+ const captureSpeed = stop.queueingForService ? PIT_QUEUE_CAPTURE_SPEED : PIT_BOX_STOP_SPEED;
2167
+ return remainingAfter <= captureDistance &&
2168
+ pointDistance(car, routeEnd) <= captureDistance &&
2169
+ car.speed <= captureSpeed;
2170
+ }
2171
+ return remainingAfter <= PIT_ROUTE_FINISH_DISTANCE;
2172
+ }
2173
+
2174
+ advancePitStopCar(car, delta) {
2175
+ const pitStops = this.rules.modules?.pitStops;
2176
+ const stop = car.pitStop;
2177
+ if (!pitStops?.enabled || !stop) return false;
2178
+ if (stop.status === 'pending' || stop.status === 'completed') this.updateAutomaticPitIntent(car);
2179
+ if (stop.status === 'completed') return false;
2180
+ if (stop.status === 'pending' && !this.shouldStartPitStop(car)) return false;
2181
+ if (stop.status === 'pending') this.startPitStop(car);
2182
+
2183
+ if (stop.status === 'entering') {
2184
+ const finishedRoute = this.applyPitRoutePosition(car, delta);
2185
+ const box = this.getPitStopBox(stop);
2186
+ if (finishedRoute && box) {
2187
+ if (stop.queueingForService) {
2188
+ if (!this.isPitServiceBusy(car, box) && !this.isPitServiceQueueOccupied(car, box)) {
2189
+ this.releasePitQueue(car, box, { fromCurrent: true });
2190
+ } else {
2191
+ this.beginPitQueue(car, box);
2192
+ }
2193
+ }
2194
+ else this.beginPitService(car, box);
2195
+ }
2196
+ car.contactCooldown = Math.max(0, car.contactCooldown - delta);
2197
+ return true;
2198
+ }
2199
+
2200
+ if (stop.status === 'queued') {
2201
+ const box = this.getPitStopBox(stop);
2202
+ if (!box) return true;
2203
+ car.previousX = car.x;
2204
+ car.previousY = car.y;
2205
+ car.previousHeading = car.heading;
2206
+ car.x = box.queuePoint.x;
2207
+ car.y = box.queuePoint.y;
2208
+ car.heading = this.track.pitLane.mainLane.heading;
2209
+ car.speed = 0;
2210
+ car.throttle = 0;
2211
+ car.brake = 1;
2212
+ applyWheelSurfaceState(car, this.track);
2213
+ car.progress = car.trackState.distance;
2214
+ car.raceDistance = this.getPitBoxRaceDistance(stop, {
2215
+ ...box,
2216
+ distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
2217
+ });
2218
+ if (!this.isPitServiceBusy(car, box)) this.releasePitQueue(car, box);
2219
+ return true;
2220
+ }
2221
+
2222
+ if (stop.status === 'servicing') {
2223
+ const box = this.getPitStopBox(stop);
2224
+ if (!box) return true;
2225
+ if (stop.phase === 'penalty') {
2226
+ stop.penaltyServiceRemaining = Math.max(0, (stop.penaltyServiceRemaining ?? 0) - delta);
2227
+ stop.serviceRemaining = stop.penaltyServiceRemaining;
2228
+ } else {
2229
+ stop.serviceRemaining = Math.max(0, (stop.serviceRemaining ?? 0) - delta);
2230
+ }
2231
+ car.previousX = car.x;
2232
+ car.previousY = car.y;
2233
+ car.previousHeading = car.heading;
2234
+ car.x = box.center.x;
2235
+ car.y = box.center.y;
2236
+ car.heading = this.track.pitLane.mainLane.heading;
2237
+ car.speed = 0;
2238
+ car.throttle = 0;
2239
+ car.brake = 1;
2240
+ applyWheelSurfaceState(car, this.track);
2241
+ car.progress = car.trackState.distance;
2242
+ car.raceDistance = this.getPitBoxRaceDistance(stop, box);
2243
+ if (stop.phase === 'penalty' && stop.penaltyServiceRemaining <= 0) {
2244
+ this.completePitPenaltyService(car);
2245
+ } else if (stop.serviceRemaining <= 0) {
2246
+ this.completePitService(car);
2247
+ }
2248
+ return true;
2249
+ }
2250
+
2251
+ if (stop.status === 'exiting') {
2252
+ const finishedRoute = this.applyPitRoutePosition(car, delta);
2253
+ if (finishedRoute) this.finishPitExit(car);
2254
+ car.contactCooldown = Math.max(0, car.contactCooldown - delta);
2255
+ return true;
2256
+ }
2257
+
2258
+ return false;
2259
+ }
2260
+
2261
+ step(dt) {
2262
+ const delta = clamp(dt, 0, 1 / 20);
694
2263
  if (!Number.isFinite(delta) || delta <= 0) return;
695
2264
 
696
2265
  this.time += delta;
@@ -704,6 +2273,24 @@ export class F1RaceSimulation {
704
2273
  return;
705
2274
  }
706
2275
 
2276
+ if (this.raceControl.redFlag) {
2277
+ this.cars.forEach((car) => {
2278
+ car.previousX = car.x;
2279
+ car.previousY = car.y;
2280
+ car.previousHeading = car.heading;
2281
+ car.speed = 0;
2282
+ car.throttle = 0;
2283
+ car.brake = 1;
2284
+ car.drsActive = false;
2285
+ car.drsEligible = false;
2286
+ car.drsZoneId = null;
2287
+ car.drsZoneEnabled = false;
2288
+ car.canAttack = false;
2289
+ });
2290
+ this.recalculateRaceState({ updateDrs: false });
2291
+ return;
2292
+ }
2293
+
707
2294
  this.updateSafetyCar(delta);
708
2295
 
709
2296
  const orderedCars = this.orderedCars();
@@ -713,6 +2300,7 @@ export class F1RaceSimulation {
713
2300
  car.previousY = car.y;
714
2301
  car.previousHeading = car.heading;
715
2302
  car.previousProgress = car.progress;
2303
+ if (this.advancePitStopCar(car, delta)) return;
716
2304
  const controls = decideDriverControls({
717
2305
  car,
718
2306
  orderIndex: index,
@@ -725,11 +2313,169 @@ export class F1RaceSimulation {
725
2313
 
726
2314
  this.resolveCollisions();
727
2315
  this.recalculateRaceState();
728
- this.evaluateRaceFinish();
2316
+ this.reviewTrackLimits();
2317
+ this.reviewPitLaneSpeeding();
2318
+ }
2319
+
2320
+ recordPenalty(penalty) {
2321
+ const car = this.cars.find((item) => item.id === penalty.driverId);
2322
+ const entry = createPenaltyRecord({
2323
+ sequence: this.nextPenaltyId,
2324
+ time: this.time,
2325
+ lap: this.computeLap(car?.raceDistance ?? 0),
2326
+ penalty,
2327
+ });
2328
+ this.nextPenaltyId += 1;
2329
+ this.penalties.push(entry);
2330
+ if (entry.gridDrop > 0 && this.raceControl.mode === 'pre-start') {
2331
+ this.applyGridDrop(entry.driverId, entry.gridDrop);
2332
+ }
2333
+ this.events.unshift(createPenaltyEvent(entry));
2334
+ return entry;
2335
+ }
2336
+
2337
+ servePenalty(penaltyId) {
2338
+ const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2339
+ const result = servePenaltyRecord(penalty, this.time);
2340
+ if (result) {
2341
+ this.events.unshift({
2342
+ type: 'penalty-served',
2343
+ at: this.time,
2344
+ penaltyId: result.id,
2345
+ driverId: result.driverId,
2346
+ serviceType: result.serviceType,
2347
+ });
2348
+ }
2349
+ return result;
2350
+ }
2351
+
2352
+ cancelPenalty(penaltyId) {
2353
+ const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2354
+ const result = cancelPenaltyRecord(penalty, this.time);
2355
+ if (result) {
2356
+ this.events.unshift({
2357
+ type: 'penalty-cancelled',
2358
+ at: this.time,
2359
+ penaltyId: result.id,
2360
+ driverId: result.driverId,
2361
+ });
2362
+ }
2363
+ return result;
2364
+ }
2365
+
2366
+ getDriverPenaltySeconds(driverId) {
2367
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).seconds;
2368
+ }
2369
+
2370
+ getDriverPositionDrop(driverId) {
2371
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).positionDrop;
2372
+ }
2373
+
2374
+ isDriverDisqualified(driverId) {
2375
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).disqualified;
2376
+ }
2377
+
2378
+ getPenaltyStatsByDriver() {
2379
+ return buildPenaltyStatsByDriver(this.penalties);
2380
+ }
2381
+
2382
+ applyGridDrop(driverId, positions) {
2383
+ const drop = Math.max(0, Math.floor(Number(positions) || 0));
2384
+ if (drop <= 0) return;
2385
+
2386
+ const ordered = [...this.cars].sort((left, right) => {
2387
+ const delta = right.gridDistance - left.gridDistance;
2388
+ return delta === 0 ? left.index - right.index : delta;
2389
+ });
2390
+ const currentIndex = ordered.findIndex((car) => car.id === driverId);
2391
+ if (currentIndex < 0) return;
2392
+ const [car] = ordered.splice(currentIndex, 1);
2393
+ ordered.splice(Math.min(ordered.length, currentIndex + drop), 0, car);
2394
+
2395
+ ordered.forEach((entry, index) => {
2396
+ const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart: true });
2397
+ const gridPoint = pointAt(this.track, gridDistance);
2398
+ const position = offsetTrackPoint(gridPoint, gridOffset);
2399
+ entry.gridDistance = gridDistance;
2400
+ entry.gridOffset = gridOffset;
2401
+ entry.rank = index + 1;
2402
+ if (entry.gridLocked) {
2403
+ entry.x = position.x;
2404
+ entry.y = position.y;
2405
+ entry.previousX = position.x;
2406
+ entry.previousY = position.y;
2407
+ entry.heading = gridPoint.heading;
2408
+ entry.previousHeading = gridPoint.heading;
2409
+ entry.progress = gridPoint.distance;
2410
+ entry.raceDistance = gridDistance;
2411
+ applyWheelSurfaceState(entry, this.track);
2412
+ }
2413
+ });
2414
+ }
2415
+
2416
+ reviewCollision(first, second, collision) {
2417
+ const rule = getPenaltyRule(this.rules, 'collision');
2418
+ calculateCollisionPenalties({ first, second, collision, rule })
2419
+ .forEach((penalty) => this.recordPenalty(penalty));
2420
+ }
2421
+
2422
+ reviewTireRequirement(car) {
2423
+ if (this.stewardState.tireRequirement[car.id]) return;
2424
+ const rule = getPenaltyRule(this.rules, 'tireRequirement');
2425
+ const penalty = calculateTireRequirementPenalty({
2426
+ car,
2427
+ tireStrategy: this.rules.modules?.tireStrategy,
2428
+ rule,
2429
+ });
2430
+ this.stewardState.tireRequirement[car.id] = true;
2431
+ if (penalty) this.recordPenalty(penalty);
2432
+ }
2433
+
2434
+ reviewTrackLimits() {
2435
+ const rule = getPenaltyRule(this.rules, 'trackLimits');
2436
+ if (!rule) return;
2437
+
2438
+ this.cars.forEach((car) => {
2439
+ const currentState = this.stewardState.trackLimits[car.id];
2440
+ if (isLegallyInsidePitLaneForTrackLimits(car) && !currentState?.active) {
2441
+ return;
2442
+ }
2443
+ const review = calculateTrackLimitReview({
2444
+ car,
2445
+ rule,
2446
+ track: this.track,
2447
+ stewardState: currentState,
2448
+ });
2449
+ this.stewardState.trackLimits[car.id] = review.nextState;
2450
+ if (review.event) this.events.unshift({ ...review.event, at: this.time });
2451
+ if (review.penalty) this.recordPenalty(review.penalty);
2452
+ });
2453
+ }
2454
+
2455
+ reviewPitLaneSpeeding() {
2456
+ const rule = getPenaltyRule(this.rules, 'pitLaneSpeeding');
2457
+ if (!rule) return;
2458
+
2459
+ this.cars.forEach((car) => {
2460
+ const currentState = this.stewardState.pitLaneSpeeding[car.id];
2461
+ const review = calculatePitLaneSpeedingReview({
2462
+ car: {
2463
+ ...car,
2464
+ speedKph: simSpeedToKph(car.speed),
2465
+ },
2466
+ rule,
2467
+ stewardState: currentState,
2468
+ });
2469
+ this.stewardState.pitLaneSpeeding[car.id] = review.nextState;
2470
+ if (review.event) this.events.unshift({ ...review.event, at: this.time });
2471
+ if (review.penalty) this.recordPenalty(review.penalty);
2472
+ });
729
2473
  }
730
2474
 
731
2475
  snapshot() {
732
2476
  const ordered = this.orderedCars();
2477
+ const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
2478
+ const penaltyStats = this.getPenaltyStatsByDriver();
733
2479
  return {
734
2480
  time: this.time,
735
2481
  world: WORLD,
@@ -737,6 +2483,9 @@ export class F1RaceSimulation {
737
2483
  totalLaps: this.totalLaps,
738
2484
  raceControl: {
739
2485
  mode: this.raceControl.mode,
2486
+ redFlag: Boolean(this.raceControl.redFlag),
2487
+ pitLaneOpen: pitLaneStatus.open,
2488
+ pitLaneStatus,
740
2489
  finished: this.raceControl.finished,
741
2490
  finishedAt: this.raceControl.finishedAt,
742
2491
  winner: this.getRaceWinnerSnapshot(),
@@ -747,23 +2496,99 @@ export class F1RaceSimulation {
747
2496
  (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
748
2497
  },
749
2498
  },
2499
+ pitLaneStatus,
750
2500
  safetyCar: { ...this.safetyCar },
751
2501
  rules: this.rules,
752
2502
  events: [...this.events],
753
- cars: ordered.map((car, index) => serializeCar(car, index + 1)),
2503
+ penalties: this.penalties.map(serializePenalty),
2504
+ cars: ordered.map((car, index) => serializeCar(car, index + 1, getPenaltyStats(penaltyStats, car.id).seconds)),
2505
+ };
2506
+ }
2507
+
2508
+ snapshotRender() {
2509
+ const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
2510
+ return {
2511
+ time: this.time,
2512
+ world: WORLD,
2513
+ track: this.track,
2514
+ totalLaps: this.totalLaps,
2515
+ raceControl: {
2516
+ mode: this.raceControl.mode,
2517
+ redFlag: Boolean(this.raceControl.redFlag),
2518
+ pitLaneOpen: pitLaneStatus.open,
2519
+ pitLaneStatus,
2520
+ finished: this.raceControl.finished,
2521
+ start: {
2522
+ ...this.raceControl.start,
2523
+ visible: this.raceControl.mode === 'pre-start' ||
2524
+ (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
2525
+ },
2526
+ },
2527
+ pitLaneStatus,
2528
+ safetyCar: { ...this.safetyCar },
2529
+ cars: this.orderedCars().map(serializeRenderCar),
754
2530
  };
755
2531
  }
756
2532
 
2533
+ snapshotObservation() {
2534
+ const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
2535
+ return {
2536
+ time: this.time,
2537
+ world: WORLD,
2538
+ track: this.track,
2539
+ totalLaps: this.totalLaps,
2540
+ raceControl: {
2541
+ mode: this.raceControl.mode,
2542
+ redFlag: Boolean(this.raceControl.redFlag),
2543
+ pitLaneOpen: pitLaneStatus.open,
2544
+ pitLaneStatus,
2545
+ finished: this.raceControl.finished,
2546
+ },
2547
+ pitLaneStatus,
2548
+ safetyCar: { ...this.safetyCar },
2549
+ events: [...this.events],
2550
+ cars: this.orderedCars().map((car, index) => serializeObservationCar(car, index + 1)),
2551
+ };
2552
+ }
2553
+
2554
+ consumeStepEvents() {
2555
+ return [...this.events];
2556
+ }
2557
+
757
2558
  orderedCars() {
2559
+ const sortLive = (cars) => [...cars].sort((a, b) => {
2560
+ const delta = b.raceDistance - a.raceDistance;
2561
+ return delta === 0 ? a.index - b.index : delta;
2562
+ });
2563
+ const byId = new Map(this.cars.map((car) => [car.id, car]));
2564
+
2565
+ if (this.raceControl.finished && this.raceControl.classification?.length) {
2566
+ const classified = this.raceControl.classification.map((entry) => byId.get(entry.id)).filter(Boolean);
2567
+ const classifiedIds = new Set(classified.map((car) => car.id));
2568
+ return [
2569
+ ...classified,
2570
+ ...sortLive(this.cars.filter((car) => !classifiedIds.has(car.id))),
2571
+ ];
2572
+ }
2573
+
2574
+ if (this.raceControl.finishOrder?.length) {
2575
+ const finished = this.raceControl.finishOrder.map((id) => byId.get(id)).filter(Boolean);
2576
+ const finishedIds = new Set(finished.map((car) => car.id));
2577
+ const missedFinished = this.cars
2578
+ .filter((car) => car.finished && !finishedIds.has(car.id))
2579
+ .sort((a, b) => {
2580
+ const delta = (a.finishRank ?? Infinity) - (b.finishRank ?? Infinity);
2581
+ return delta === 0 ? a.index - b.index : delta;
2582
+ });
2583
+ const running = sortLive(this.cars.filter((car) => !finishedIds.has(car.id) && !car.finished));
2584
+ return [...finished, ...missedFinished, ...running];
2585
+ }
2586
+
758
2587
  if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
759
- const byId = new Map(this.cars.map((car) => [car.id, car]));
760
2588
  return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
761
2589
  }
762
2590
 
763
- return [...this.cars].sort((a, b) => {
764
- const delta = b.raceDistance - a.raceDistance;
765
- return delta === 0 ? a.index - b.index : delta;
766
- });
2591
+ return sortLive(this.cars);
767
2592
  }
768
2593
 
769
2594
  driverRaceContext(orderedCars = this.orderedCars()) {
@@ -826,9 +2651,10 @@ export class F1RaceSimulation {
826
2651
  const wasGridLocked = car.gridLocked;
827
2652
  car.gridLocked = false;
828
2653
  const state = nearestTrackState(this.track, car, car.gridDistance);
829
- car.trackState = state;
830
2654
  car.progress = state.distance;
2655
+ applyWheelSurfaceState(car, this.track);
831
2656
  if (wasGridLocked) car.raceDistance = car.gridDistance;
2657
+ if (wasGridLocked) car.desiredOffset = 0;
832
2658
  car.previousX = car.x;
833
2659
  car.previousY = car.y;
834
2660
  car.previousHeading = car.heading;
@@ -854,7 +2680,7 @@ export class F1RaceSimulation {
854
2680
  car.turnRadius = Infinity;
855
2681
  car.progress = gridPoint.distance;
856
2682
  car.raceDistance = car.gridDistance;
857
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
2683
+ applyWheelSurfaceState(car, this.track);
858
2684
  });
859
2685
  }
860
2686
 
@@ -862,7 +2688,9 @@ export class F1RaceSimulation {
862
2688
  if (!this.safetyCar.deployed) return;
863
2689
  const leader = this.orderedCars()[0];
864
2690
  const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
865
- const progress = Math.max(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress);
2691
+ const progress = this.safetyCar.progress < targetProgress
2692
+ ? Math.min(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress)
2693
+ : targetProgress;
866
2694
  this.moveSafetyCarTo(progress);
867
2695
  }
868
2696
 
@@ -879,10 +2707,14 @@ export class F1RaceSimulation {
879
2707
 
880
2708
  applyRunoffResponse(car) {
881
2709
  const state = nearestTrackState(this.track, car, car.progress);
2710
+ if (state.inPitLane) {
2711
+ applyWheelSurfaceState(car, this.track, { centerState: state });
2712
+ return;
2713
+ }
882
2714
  const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
883
2715
  const overshoot = Math.abs(state.signedOffset) - signedLimit;
884
2716
  if (overshoot <= 0) {
885
- car.trackState = state;
2717
+ applyWheelSurfaceState(car, this.track, { centerState: state });
886
2718
  return;
887
2719
  }
888
2720
 
@@ -891,7 +2723,7 @@ export class F1RaceSimulation {
891
2723
  car.y -= state.normalY * side * overshoot;
892
2724
  car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
893
2725
  car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
894
- car.trackState = nearestTrackState(this.track, car, state.distance);
2726
+ applyWheelSurfaceState(car, this.track);
895
2727
  }
896
2728
 
897
2729
  recalculateRaceState({ updateDrs = true } = {}) {
@@ -899,7 +2731,7 @@ export class F1RaceSimulation {
899
2731
  const previousRaceDistance = car.raceDistance;
900
2732
  if (car.gridLocked) {
901
2733
  const gridPoint = pointAt(this.track, car.gridDistance);
902
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
2734
+ applyWheelSurfaceState(car, this.track);
903
2735
  car.progress = gridPoint.distance;
904
2736
  car.raceDistance = car.gridDistance;
905
2737
  car.lap = 1;
@@ -907,7 +2739,7 @@ export class F1RaceSimulation {
907
2739
  return;
908
2740
  }
909
2741
 
910
- car.trackState = nearestTrackState(this.track, car, car.progress);
2742
+ applyWheelSurfaceState(car, this.track);
911
2743
  const previousProgress = car.progress ?? car.trackState.distance;
912
2744
  const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
913
2745
  car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
@@ -917,20 +2749,41 @@ export class F1RaceSimulation {
917
2749
  });
918
2750
 
919
2751
  updateSectorPerformance(this.cars);
920
- this.cars.forEach((car) => recordTimingSample(car, this.time));
2752
+ this.cars.forEach((car) => {
2753
+ recordTimingSample(car, this.time);
2754
+ recordTimingLineCrossings(car, car.previousRaceDistanceForTiming, this.time, this.track);
2755
+ car.previousRaceDistanceForTiming = car.raceDistance;
2756
+ });
921
2757
 
922
2758
  const ordered = this.orderedCars();
2759
+ const leader = ordered[0];
923
2760
  ordered.forEach((car, index) => {
924
2761
  const ahead = ordered[index - 1];
2762
+ const drsReference = this.getDrsReferenceCar(car);
925
2763
  const gap = ahead ? ahead.raceDistance - car.raceDistance : Infinity;
2764
+ const intervalAheadLaps = ahead ? wholeLapGap(ahead.raceDistance, car.raceDistance, this.track.length) : 0;
2765
+ const leaderGapLaps = leader ? wholeLapGap(leader.raceDistance, car.raceDistance, this.track.length) : 0;
2766
+ const activePitStop = this.isCarInActivePitStop(car);
926
2767
  car.rank = index + 1;
927
2768
  car.gapAhead = gap;
928
- car.gapAheadSeconds = Number.isFinite(gap) ? estimateGapAheadSeconds(ahead, car, this.time) : Infinity;
2769
+ car.gapAheadLaps = intervalAheadLaps;
2770
+ car.intervalAheadLaps = intervalAheadLaps;
2771
+ car.leaderGapLaps = leaderGapLaps;
2772
+ car.gapAheadSeconds = Number.isFinite(gap) && intervalAheadLaps === 0
2773
+ ? estimateGapAheadSeconds(ahead, car, this.time, this.track)
2774
+ : Infinity;
929
2775
  car.intervalAheadSeconds = car.gapAheadSeconds;
930
- car.leaderGapSeconds = ahead ? ahead.leaderGapSeconds + car.gapAheadSeconds : 0;
931
- car.canAttack = !this.safetyCar.deployed && !car.finished;
2776
+ car.leaderGapSeconds = leaderGapLaps > 0
2777
+ ? Infinity
2778
+ : (leader && leader !== car ? estimateGapAheadSeconds(leader, car, this.time, this.track) : 0);
2779
+ car.canAttack = !this.safetyCar.deployed && !car.finished && !activePitStop;
932
2780
  car.aggression = this.computeAggression(car, index);
933
- if (updateDrs) this.updateDrsLatch(car, ahead, index);
2781
+ if (activePitStop) {
2782
+ car.drsEligible = false;
2783
+ car.drsActive = false;
2784
+ car.drsZoneId = null;
2785
+ car.drsZoneEnabled = false;
2786
+ } else if (updateDrs) this.updateDrsLatch(car, drsReference, Boolean(drsReference));
934
2787
  });
935
2788
  this.evaluateRaceFinish();
936
2789
  }
@@ -946,7 +2799,8 @@ export class F1RaceSimulation {
946
2799
  newlyFinished.forEach((car) => {
947
2800
  car.finished = true;
948
2801
  car.finishTime = this.time;
949
- car.classifiedRank = this.raceControl.finishOrder.length + 1;
2802
+ car.finishRank = this.raceControl.finishOrder.length + 1;
2803
+ car.classifiedRank = car.finishRank;
950
2804
  this.raceControl.finishOrder.push(car.id);
951
2805
  if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
952
2806
  car.drsActive = false;
@@ -958,14 +2812,17 @@ export class F1RaceSimulation {
958
2812
  type: 'car-finish',
959
2813
  at: this.time,
960
2814
  carId: car.id,
961
- rank: car.classifiedRank,
2815
+ rank: car.finishRank,
962
2816
  winnerId: this.raceControl.winnerId,
963
2817
  });
2818
+ this.reviewTireRequirement(car);
964
2819
  });
965
2820
 
966
2821
  if (!this.cars.every((car) => car.finished)) return;
967
2822
 
2823
+ this.applyOutstandingServicePenalties();
968
2824
  const classification = this.buildClassificationFromFinishOrder();
2825
+ this.raceControl.winnerId = classification[0]?.id ?? this.raceControl.winnerId;
969
2826
  this.raceControl.mode = 'safety-car';
970
2827
  this.raceControl.finished = true;
971
2828
  this.raceControl.finishedAt = this.time;
@@ -995,32 +2852,88 @@ export class F1RaceSimulation {
995
2852
  });
996
2853
  }
997
2854
 
2855
+ applyOutstandingServicePenalties() {
2856
+ this.penalties.forEach((penalty) => {
2857
+ const beforeStatus = penalty.status;
2858
+ applyUnservedServicePenalty(penalty, this.time);
2859
+ if (beforeStatus !== penalty.status && penalty.unserved) {
2860
+ this.events.unshift({
2861
+ type: 'penalty-applied',
2862
+ at: this.time,
2863
+ penaltyId: penalty.id,
2864
+ driverId: penalty.driverId,
2865
+ serviceType: penalty.serviceType,
2866
+ penaltySeconds: penalty.penaltySeconds,
2867
+ });
2868
+ }
2869
+ });
2870
+ }
2871
+
998
2872
  buildClassificationFromFinishOrder() {
999
2873
  const byId = new Map(this.cars.map((car) => [car.id, car]));
1000
- const ordered = this.raceControl.finishOrder
1001
- .map((id) => byId.get(id))
1002
- .filter(Boolean);
1003
- return this.buildClassification(ordered);
2874
+ const penaltyStats = this.getPenaltyStatsByDriver();
2875
+ const orderedByAdjustedTime = this.raceControl.finishOrder
2876
+ .map((id, finishOrderIndex) => ({ car: byId.get(id), finishOrderIndex }))
2877
+ .filter((entry) => Boolean(entry.car))
2878
+ .sort((left, right) => {
2879
+ const leftTime = (left.car.finishTime ?? Infinity) + getPenaltyStats(penaltyStats, left.car.id).seconds;
2880
+ const rightTime = (right.car.finishTime ?? Infinity) + getPenaltyStats(penaltyStats, right.car.id).seconds;
2881
+ return leftTime === rightTime ? left.finishOrderIndex - right.finishOrderIndex : leftTime - rightTime;
2882
+ })
2883
+ .map((entry) => entry.car);
2884
+ const ordered = this.applyClassificationConsequences(orderedByAdjustedTime, penaltyStats);
2885
+ return this.buildClassification(ordered, penaltyStats);
1004
2886
  }
1005
2887
 
1006
- buildClassification(ordered = this.orderedCars()) {
2888
+ applyClassificationConsequences(ordered, penaltyStats = this.getPenaltyStatsByDriver()) {
2889
+ const classified = ordered.filter((car) => !getPenaltyStats(penaltyStats, car.id).disqualified);
2890
+ ordered.forEach((car) => {
2891
+ const stats = getPenaltyStats(penaltyStats, car.id);
2892
+ if (stats.disqualified) return;
2893
+ const drop = stats.positionDrop;
2894
+ if (drop <= 0) return;
2895
+ const currentIndex = classified.findIndex((entry) => entry.id === car.id);
2896
+ if (currentIndex < 0) return;
2897
+ const [entry] = classified.splice(currentIndex, 1);
2898
+ classified.splice(Math.min(classified.length, currentIndex + drop), 0, entry);
2899
+ });
2900
+ return [
2901
+ ...classified,
2902
+ ...ordered.filter((car) => getPenaltyStats(penaltyStats, car.id).disqualified),
2903
+ ];
2904
+ }
2905
+
2906
+ buildClassification(ordered = this.orderedCars(), penaltyStats = this.getPenaltyStatsByDriver()) {
1007
2907
  const leaderDistance = ordered[0]?.raceDistance ?? 0;
1008
- return ordered.map((car, index) => ({
1009
- id: car.id,
1010
- code: car.code,
1011
- timingCode: car.timingCode,
1012
- name: car.name,
1013
- rank: index + 1,
1014
- raceDistance: car.raceDistance,
1015
- distanceMeters: simUnitsToMeters(car.raceDistance),
1016
- lap: this.computeLap(car.raceDistance),
1017
- lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
1018
- gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
1019
- gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
1020
- intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
1021
- finished: car.raceDistance >= this.finishDistance,
1022
- finishTime: car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null),
1023
- }));
2908
+ return ordered.map((car, index) => {
2909
+ const finishTime = car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null);
2910
+ const stats = getPenaltyStats(penaltyStats, car.id);
2911
+ const penaltySeconds = stats.seconds;
2912
+ const positionDrop = stats.positionDrop;
2913
+ const disqualified = stats.disqualified;
2914
+ return {
2915
+ id: car.id,
2916
+ code: car.code,
2917
+ timingCode: car.timingCode,
2918
+ name: car.name,
2919
+ rank: index + 1,
2920
+ raceDistance: car.raceDistance,
2921
+ distanceMeters: simUnitsToMeters(car.raceDistance),
2922
+ lap: this.computeLap(car.raceDistance),
2923
+ lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
2924
+ gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
2925
+ gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
2926
+ intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
2927
+ gapLaps: index === 0 ? 0 : car.leaderGapLaps,
2928
+ intervalLaps: index === 0 ? 0 : car.intervalAheadLaps,
2929
+ finished: car.raceDistance >= this.finishDistance,
2930
+ finishTime,
2931
+ penaltySeconds,
2932
+ adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
2933
+ positionDrop,
2934
+ disqualified,
2935
+ };
2936
+ });
1024
2937
  }
1025
2938
 
1026
2939
  getRaceWinnerSnapshot() {
@@ -1028,10 +2941,21 @@ export class F1RaceSimulation {
1028
2941
  const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
1029
2942
  if (!car) return null;
1030
2943
  const rank = car.classifiedRank ?? car.rank ?? 1;
1031
- return serializeCar(car, rank);
2944
+ return serializeCar(car, rank, getPenaltyStats(this.getPenaltyStatsByDriver(), car.id).seconds);
2945
+ }
2946
+
2947
+ getDrsReferenceCar(car) {
2948
+ let closest = null;
2949
+ this.cars.forEach((candidate) => {
2950
+ if (candidate === car || candidate.finished || this.isCarInActivePitStop(candidate)) return;
2951
+ const delta = distanceForward(car.progress ?? car.raceDistance ?? 0, candidate.progress ?? candidate.raceDistance ?? 0, this.track.length);
2952
+ if (delta <= 0 || delta > this.track.length / 2) return;
2953
+ if (!closest || delta < closest.delta) closest = { car: candidate, delta };
2954
+ });
2955
+ return closest?.car ?? null;
1032
2956
  }
1033
2957
 
1034
- updateDrsLatch(car, ahead, orderIndex) {
2958
+ updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
1035
2959
  if (this.safetyCar.deployed || car.finished) {
1036
2960
  car.drsEligible = false;
1037
2961
  car.drsActive = false;
@@ -1059,7 +2983,7 @@ export class F1RaceSimulation {
1059
2983
  const crossing = recordDrsDetection(car, crossedZone.id, this.time);
1060
2984
  const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
1061
2985
  car.drsZoneEnabled = Boolean(
1062
- orderIndex > 0 &&
2986
+ hasReference &&
1063
2987
  aheadCrossing &&
1064
2988
  aheadCrossing.passage === crossing.passage &&
1065
2989
  crossing.time >= aheadCrossing.time &&
@@ -1080,36 +3004,73 @@ export class F1RaceSimulation {
1080
3004
  const reportedContacts = new Set();
1081
3005
 
1082
3006
  for (let pass = 0; pass < 3; pass += 1) {
1083
- for (let i = 0; i < this.cars.length; i += 1) {
1084
- for (let j = i + 1; j < this.cars.length; j += 1) {
1085
- const first = this.cars[i];
1086
- const second = this.cars[j];
1087
- const collision = detectObbCollision(first, second) ?? detectLongitudinalCollision(first, second);
1088
- if (!collision) continue;
1089
-
1090
- const correction = Math.min(
1091
- collision.depth / 2 + (collision.longitudinal ? 0.35 : 0.65),
1092
- MAX_COLLISION_CORRECTION,
1093
- );
1094
- first.x -= collision.axis.x * correction;
1095
- first.y -= collision.axis.y * correction;
1096
- second.x += collision.axis.x * correction;
1097
- second.y += collision.axis.y * correction;
3007
+ const candidates = buildCollisionCandidatePairs(this.cars, { trackLength: this.track.length });
3008
+ for (const [first, second] of candidates) {
3009
+ const collision = detectVehicleCollision(first, second);
3010
+ if (!collision) continue;
3011
+ const firstPitControlled = isPitPositionControlledCar(first);
3012
+ const secondPitControlled = isPitPositionControlledCar(second);
3013
+ if (firstPitControlled && secondPitControlled) continue;
3014
+ const stewardCollision = createCollisionStewardContext(first, second, {
3015
+ ...collision,
3016
+ trackLength: this.track.length,
3017
+ });
3018
+ const oneCarFixed = firstPitControlled || secondPitControlled;
3019
+
3020
+ const correction = Math.min(
3021
+ (oneCarFixed ? collision.depth : collision.depth / 2) + 0.65,
3022
+ MAX_COLLISION_CORRECTION,
3023
+ );
3024
+ const firstCorrectionX = firstPitControlled ? 0 : -collision.axis.x * correction;
3025
+ const firstCorrectionY = firstPitControlled ? 0 : -collision.axis.y * correction;
3026
+ const secondCorrectionX = secondPitControlled ? 0 : collision.axis.x * correction;
3027
+ const secondCorrectionY = secondPitControlled ? 0 : collision.axis.y * correction;
3028
+ if (!firstPitControlled) {
3029
+ first.x += firstCorrectionX;
3030
+ first.y += firstCorrectionY;
3031
+ }
3032
+ if (!secondPitControlled) {
3033
+ second.x += secondCorrectionX;
3034
+ second.y += secondCorrectionY;
3035
+ }
1098
3036
 
3037
+ if (oneCarFixed) {
3038
+ if (!firstPitControlled) first.speed = clamp(first.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
3039
+ if (!secondPitControlled) second.speed = clamp(second.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
3040
+ } else {
1099
3041
  this.applyContactVelocityResponse(first, second, collision.axis);
3042
+ }
1100
3043
 
1101
- const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
1102
- const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
1103
- first.heading = normalizeAngle(first.heading - collision.axis.y * yawNudge);
1104
- second.heading = normalizeAngle(second.heading + collision.axis.y * yawNudge);
1105
- first.contactCooldown = 1;
1106
- second.contactCooldown = 1;
1107
-
1108
- const contactKey = `${first.id}:${second.id}`;
1109
- if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
1110
- reportedContacts.add(contactKey);
1111
- this.events.unshift({ type: 'contact', at: this.time, carId: first.id, otherCarId: second.id });
1112
- }
3044
+ const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
3045
+ const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
3046
+ const firstHeadingCorrection = firstPitControlled ? 0 : -collision.axis.y * yawNudge;
3047
+ const secondHeadingCorrection = secondPitControlled ? 0 : collision.axis.y * yawNudge;
3048
+ if (!firstPitControlled) {
3049
+ first.heading = normalizeAngle(first.heading + firstHeadingCorrection);
3050
+ shiftPreviousRenderPose(first, firstCorrectionX, firstCorrectionY, firstHeadingCorrection);
3051
+ }
3052
+ if (!secondPitControlled) {
3053
+ second.heading = normalizeAngle(second.heading + secondHeadingCorrection);
3054
+ shiftPreviousRenderPose(second, secondCorrectionX, secondCorrectionY, secondHeadingCorrection);
3055
+ }
3056
+ first.contactCooldown = 1;
3057
+ second.contactCooldown = 1;
3058
+
3059
+ const contactKey = `${first.id}:${second.id}`;
3060
+ if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
3061
+ reportedContacts.add(contactKey);
3062
+ this.events.unshift({
3063
+ type: 'contact',
3064
+ at: this.time,
3065
+ carId: first.id,
3066
+ otherCarId: second.id,
3067
+ firstShapeId: collision.firstShapeId,
3068
+ secondShapeId: collision.secondShapeId,
3069
+ contactType: collision.contactType,
3070
+ depth: collision.depth,
3071
+ timeOfImpact: collision.timeOfImpact,
3072
+ });
3073
+ this.reviewCollision(first, second, stewardCollision);
1113
3074
  }
1114
3075
  }
1115
3076
  }