@inventure71/paddockjs 0.2.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 (63) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/INSTALL_AND_UPDATE.md +165 -0
  3. package/README.md +217 -20
  4. package/docs/architecture.md +84 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +412 -20
  7. package/docs/data_model.md +2 -1
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +1 -1
  10. package/docs/rules.md +140 -19
  11. package/docs/system_specs.md +74 -20
  12. package/docs/training.md +206 -0
  13. package/examples/train-basic-policy.mjs +276 -0
  14. package/examples/trainingData.mjs +133 -0
  15. package/package.json +18 -8
  16. package/src/api/PaddockSimulatorController.js +39 -4
  17. package/src/app/BrowserExpertAdapter.js +32 -0
  18. package/src/app/F1SimulatorApp.js +1251 -146
  19. package/src/app/domBindings.js +10 -2
  20. package/src/config/defaultOptions.js +11 -3
  21. package/src/data/championship.js +13 -0
  22. package/src/data/normalizeDrivers.js +7 -2
  23. package/src/environment/actions.js +68 -0
  24. package/src/environment/episode.js +17 -0
  25. package/src/environment/evaluation.js +184 -0
  26. package/src/environment/events.js +17 -0
  27. package/src/environment/index.d.ts +521 -0
  28. package/src/environment/index.js +15 -0
  29. package/src/environment/observationOptions.js +12 -0
  30. package/src/environment/observations.js +284 -0
  31. package/src/environment/options.js +157 -0
  32. package/src/environment/recorder.js +28 -0
  33. package/src/environment/rewards.js +69 -0
  34. package/src/environment/runtime.js +175 -0
  35. package/src/environment/scenarios.js +220 -0
  36. package/src/environment/sensors.js +367 -0
  37. package/src/environment/specs.js +163 -0
  38. package/src/environment/workerProtocol.js +52 -0
  39. package/src/index.d.ts +405 -6
  40. package/src/index.js +27 -2
  41. package/src/rendering/proceduralTrackAsset.js +343 -24
  42. package/src/simulation/collisionGeometry.js +214 -0
  43. package/src/simulation/driverController.js +431 -65
  44. package/src/simulation/raceSimulation.js +2140 -171
  45. package/src/simulation/rules/collisionSteward.js +69 -0
  46. package/src/simulation/rules/penaltyLedger.js +153 -0
  47. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  48. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  49. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  50. package/src/simulation/rulesConfig.js +352 -0
  51. package/src/simulation/trackModel.js +1154 -76
  52. package/src/simulation/units.js +6 -4
  53. package/src/simulation/vehicleGeometry.js +247 -0
  54. package/src/simulation/vehiclePhysics.js +82 -50
  55. package/src/simulation/wheelSurface.js +338 -0
  56. package/src/styles.css +369 -67
  57. package/src/ui/bannerTemplates.js +69 -0
  58. package/src/ui/componentTemplates.js +45 -81
  59. package/src/ui/raceControlStatusBanner.js +39 -0
  60. package/src/ui/shellTemplate.js +1 -1
  61. package/src/ui/templateUtils.js +23 -0
  62. package/AGENTS.md +0 -86
  63. package/src/standalone.js +0 -15
@@ -8,30 +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;
35
+ export const FIXED_STEP = 1 / 60;
36
+ const MIN_TOTAL_LAPS = 1;
16
37
  const MAX_COLLISION_CORRECTION = 4.5;
17
- const GRID_SLOT_SPACING = 82;
18
- const GRID_FIRST_SLOT_DISTANCE = -42;
19
- 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);
20
43
  const TIMING_HISTORY_WINDOW_SECONDS = 18;
21
44
  const TIMING_HISTORY_MAX_SAMPLES = 720;
45
+ const TIMING_LINE_TARGET_SPACING_METERS = 175;
46
+ const TIMING_LINE_HISTORY_LAPS = 3;
22
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
+ }
23
79
 
24
- export const DEFAULT_RULES = {
25
- drsDetectionSeconds: 1,
26
- safetyCarSpeed: 46,
27
- safetyCarLeadDistance: 122,
28
- safetyCarGap: 128,
29
- collisionRestitution: 0.18,
30
- standingStart: true,
31
- startLightCount: 5,
32
- startLightInterval: 0.72,
33
- startLightsOutHold: 0.78,
34
- };
80
+ function isPitPositionControlledCar(car) {
81
+ const status = car?.pitStop?.status;
82
+ return status === 'entering' || status === 'queued' || status === 'servicing' || status === 'exiting';
83
+ }
35
84
 
36
85
  function wrapProgress(value, length) {
37
86
  return wrapDistance(value, length);
@@ -95,6 +144,15 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
95
144
  };
96
145
  }
97
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
+
98
156
  function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
99
157
  const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
100
158
  return {
@@ -105,6 +163,8 @@ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps
105
163
  currentLapTime: 0,
106
164
  currentSectorElapsed: 0,
107
165
  currentSectors: createEmptySectorTimes(),
166
+ sectorProgress: createSectorProgress(position),
167
+ liveSectors: createEmptySectorTimes(),
108
168
  lastLapTime: null,
109
169
  bestLapTime: null,
110
170
  lastSectors: createEmptySectorTimes(),
@@ -133,6 +193,8 @@ function serializeLapTelemetry(telemetry) {
133
193
  currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
134
194
  currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
135
195
  currentSectors: serializeSectorTimes(telemetry.currentSectors),
196
+ sectorProgress: serializeSectorTimes(telemetry.sectorProgress),
197
+ liveSectors: serializeSectorTimes(telemetry.liveSectors),
136
198
  lastLapTime: finiteOrNull(telemetry.lastLapTime),
137
199
  bestLapTime: finiteOrNull(telemetry.bestLapTime),
138
200
  lastSectors: serializeSectorTimes(telemetry.lastSectors),
@@ -157,14 +219,36 @@ function updateBestSector(telemetry, sectorIndex, sectorTime) {
157
219
  }
158
220
  }
159
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
+
160
241
  function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
161
242
  const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
162
243
  telemetry.currentLap = position.currentLap;
163
244
  telemetry.currentSector = position.currentSector;
164
245
  telemetry.currentSectorProgress = position.currentSectorProgress;
246
+ clearFutureSectorTelemetry(telemetry);
247
+ telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
165
248
  telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
166
249
  telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
167
250
  telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
251
+ syncLiveSectorTelemetry(telemetry, track);
168
252
  }
169
253
 
170
254
  function classifySectorPerformance(value, personalBest, overallBest) {
@@ -255,14 +339,13 @@ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, total
255
339
  }
256
340
 
257
341
  function createCar(driver, index, random, track, { standingStart = false } = {}) {
258
- const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
342
+ const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart });
259
343
  const start = pointAt(track, gridDistance);
260
- const offset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
261
- const position = offsetTrackPoint(start, offset);
344
+ const position = offsetTrackPoint(start, gridOffset);
262
345
  const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
263
346
  const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
264
347
  const personality = buildDriverPersonality(driver, index, racecraft, random);
265
- const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
348
+ const launchSpeed = kphToSimSpeed(Math.max(70, 84 - index * 0.55 + pace * 4));
266
349
  const vehicle = driver.vehicle ?? {};
267
350
 
268
351
  return {
@@ -305,8 +388,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
305
388
  aggression: personality.baseAggression,
306
389
  gridLocked: standingStart,
307
390
  gridDistance,
308
- gridOffset: offset,
309
- desiredOffset: offset,
391
+ gridOffset,
392
+ desiredOffset: gridOffset,
310
393
  progress: start.distance,
311
394
  raceDistance: gridDistance,
312
395
  lap: 1,
@@ -315,6 +398,9 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
315
398
  gapAheadSeconds: Infinity,
316
399
  leaderGapSeconds: 0,
317
400
  intervalAheadSeconds: Infinity,
401
+ gapAheadLaps: 0,
402
+ intervalAheadLaps: 0,
403
+ leaderGapLaps: 0,
318
404
  drsEligible: false,
319
405
  drsActive: false,
320
406
  drsZoneId: null,
@@ -326,85 +412,70 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
326
412
  trackState: start,
327
413
  contactCooldown: 0,
328
414
  tireEnergy: 100,
415
+ usedTireCompounds: [driver.tire ?? ['M', 'H', 'S'][index % 3]],
416
+ automaticPitIntentEnabled: true,
329
417
  };
330
418
  }
331
419
 
332
- function projectOntoAxis(points, axis) {
333
- let min = Infinity;
334
- let max = -Infinity;
335
- points.forEach((point) => {
336
- const projection = point.x * axis.x + point.y * axis.y;
337
- min = Math.min(min, projection);
338
- max = Math.max(max, projection);
339
- });
340
- return { min, max };
341
- }
342
-
343
- function overlapOnAxis(a, b, axis) {
344
- const first = projectOntoAxis(a, axis);
345
- const second = projectOntoAxis(b, axis);
346
- return Math.min(first.max, second.max) - Math.max(first.min, second.min);
347
- }
348
-
349
- function detectObbCollision(a, b) {
350
- const aCorners = getCarCorners(a);
351
- const bCorners = getCarCorners(b);
352
- const axes = [
353
- normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
354
- normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
355
- normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
356
- normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
357
- ];
358
-
359
- let minimumOverlap = Infinity;
360
- let minimumAxis = null;
361
-
362
- for (const axis of axes) {
363
- const overlap = overlapOnAxis(aCorners, bCorners, axis);
364
- if (overlap <= 0) return null;
365
- if (overlap < minimumOverlap) {
366
- minimumOverlap = overlap;
367
- minimumAxis = axis;
368
- }
369
- }
370
-
371
- const direction = { x: b.x - a.x, y: b.y - a.y };
372
- if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
373
- minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
374
- }
420
+ function normalizeTotalLaps(value) {
421
+ const numeric = Number(value);
422
+ if (!Number.isFinite(numeric)) return MIN_TOTAL_LAPS;
423
+ return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
424
+ }
375
425
 
376
- return { axis: minimumAxis, depth: minimumOverlap };
426
+ function forwardVector(car) {
427
+ return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
377
428
  }
378
429
 
379
- function detectLongitudinalCollision(a, b) {
380
- const longitudinalGap = b.raceDistance - a.raceDistance;
381
- const headingDelta = Math.abs(normalizeAngle(a.heading - b.heading));
382
- const blendedHeading = a.heading + normalizeAngle(b.heading - a.heading) * 0.5;
383
- let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
384
- const direction = { x: b.x - a.x, y: b.y - a.y };
385
- const physicalLongitudinalGap = dot(direction, axis);
386
- 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
+ }
387
434
 
388
- const physicalLongitudinalSeparation = Math.abs(physicalLongitudinalGap);
389
- const lateralGap = Math.abs(direction.x * -axis.y + direction.y * axis.x);
390
- 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
+ }
391
456
 
392
- if (Math.abs(longitudinalGap) > VEHICLE_LIMITS.carLength * 1.45) return null;
393
- if (physicalLongitudinalSeparation >= requiredGap) return null;
394
- if (lateralGap > VEHICLE_LIMITS.carWidth * 0.82) return null;
395
- 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
+ };
396
470
 
397
471
  return {
398
- axis,
399
- depth: requiredGap - physicalLongitudinalSeparation,
400
- longitudinal: true,
472
+ ...collision,
473
+ impactSpeed: Math.max(0, dot(relativeVelocity, directionBehindToAhead)),
474
+ aheadDriverId: ahead.id,
475
+ atFaultDriverId: behind.id,
401
476
  };
402
477
  }
403
478
 
404
- function forwardVector(car) {
405
- return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
406
- }
407
-
408
479
  function dot(a, b) {
409
480
  return a.x * b.x + a.y * b.y;
410
481
  }
@@ -469,9 +540,12 @@ function interpolateTimeAtDistance(history, targetDistance) {
469
540
  return null;
470
541
  }
471
542
 
472
- function estimateGapAheadSeconds(ahead, car, currentTime) {
543
+ function estimateGapAheadSeconds(ahead, car, currentTime, track) {
473
544
  if (!ahead) return Infinity;
474
545
 
546
+ const timingLineGap = estimateTimingLineGapSeconds(ahead, car, currentTime, track);
547
+ if (Number.isFinite(timingLineGap)) return timingLineGap;
548
+
475
549
  const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
476
550
  if (Number.isFinite(crossingTime)) {
477
551
  return Math.max(0, currentTime - crossingTime);
@@ -481,6 +555,106 @@ function estimateGapAheadSeconds(ahead, car, currentTime) {
481
555
  return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
482
556
  }
483
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
+
484
658
  function recordDrsDetection(car, zoneId, currentTime) {
485
659
  const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
486
660
  const next = { passage: previous.passage + 1, time: currentTime };
@@ -491,7 +665,403 @@ function recordDrsDetection(car, zoneId, currentTime) {
491
665
  return next;
492
666
  }
493
667
 
494
- 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';
495
1065
  return {
496
1066
  id: car.id,
497
1067
  code: car.code,
@@ -523,8 +1093,14 @@ function serializeCar(car, rank) {
523
1093
  },
524
1094
  rank,
525
1095
  classifiedRank: car.classifiedRank ?? rank,
1096
+ finishRank: car.finishRank ?? null,
1097
+ status: raceStatus,
1098
+ raceStatus,
1099
+ wavedFlag: Boolean(car.finished),
526
1100
  finished: Boolean(car.finished),
527
- finishTime: car.finishTime ?? null,
1101
+ finishTime,
1102
+ penaltySeconds,
1103
+ adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
528
1104
  previousX: car.previousX ?? car.x,
529
1105
  previousY: car.previousY ?? car.y,
530
1106
  x: car.x,
@@ -549,6 +1125,9 @@ function serializeCar(car, rank) {
549
1125
  gapAheadSeconds: car.gapAheadSeconds,
550
1126
  intervalAheadSeconds: car.intervalAheadSeconds,
551
1127
  leaderGapSeconds: car.leaderGapSeconds,
1128
+ gapAheadLaps: car.gapAheadLaps ?? 0,
1129
+ intervalAheadLaps: car.intervalAheadLaps ?? car.gapAheadLaps ?? 0,
1130
+ leaderGapLaps: car.leaderGapLaps ?? 0,
552
1131
  drsEligible: car.drsEligible,
553
1132
  drsActive: car.drsActive,
554
1133
  drsZoneId: car.drsZoneId,
@@ -557,27 +1136,161 @@ function serializeCar(car, rank) {
557
1136
  signedOffset: car.trackState?.signedOffset ?? 0,
558
1137
  crossTrackError: car.trackState?.crossTrackError ?? 0,
559
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,
560
1144
  contactCooldown: car.contactCooldown,
561
1145
  tireEnergy: car.tireEnergy,
1146
+ usedTireCompounds: [...(car.usedTireCompounds ?? [])],
1147
+ pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
1148
+ pitStop: serializePitStop(car.pitStop),
562
1149
  positionSource: 'integrated-vehicle',
563
1150
  };
564
1151
  }
565
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
+
566
1265
  export class F1RaceSimulation {
567
1266
  constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
568
1267
  this.seed = seed;
569
1268
  this.random = createMulberry32(seed);
570
1269
  const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
571
- 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);
572
1276
  this.trackSeed = this.track.seed ?? trackSeed;
573
- this.rules = { ...DEFAULT_RULES, ...rules };
1277
+ this.rules = normalizeRaceRules(rules);
574
1278
  this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
575
- this.totalLaps = totalLaps;
1279
+ this.totalLaps = normalizeTotalLaps(totalLaps);
576
1280
  this.time = 0;
577
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
+ };
578
1289
  this.raceControl = {
579
1290
  mode: this.rules.standingStart === false ? 'green' : 'pre-start',
580
1291
  frozenOrder: null,
1292
+ redFlag: false,
1293
+ pitLaneOpen: true,
581
1294
  finished: false,
582
1295
  finishedAt: null,
583
1296
  winnerId: null,
@@ -606,15 +1319,118 @@ export class F1RaceSimulation {
606
1319
  this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
607
1320
  standingStart: this.raceControl.mode === 'pre-start',
608
1321
  }));
1322
+ this.assignPitLaneTeams();
1323
+ this.initializePitStops();
609
1324
  this.recalculateRaceState({ updateDrs: false });
610
1325
  this.cars.forEach((car) => resetTimingHistory(car, this.time));
1326
+ this.cars.forEach((car) => resetTimingLineCrossings(car, this.time));
611
1327
  this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
612
1328
  }
613
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
+
614
1429
  setSafetyCar(deployed) {
615
1430
  const next = Boolean(deployed);
616
1431
  if (this.raceControl.finished) return;
617
1432
  if (next && this.raceControl.mode === 'pre-start') return;
1433
+ if (this.raceControl.redFlag) return;
618
1434
  if (next === this.safetyCar.deployed) return;
619
1435
  const ordered = this.orderedCars();
620
1436
  this.safetyCar.deployed = next;
@@ -637,9 +1453,53 @@ export class F1RaceSimulation {
637
1453
  this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
638
1454
  }
639
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
+
640
1499
  setCarState(id, partial) {
641
1500
  const car = this.cars.find((item) => item.id === id);
642
1501
  if (!car) return;
1502
+ const hasExplicitRaceDistance = partial.raceDistance != null;
643
1503
  Object.assign(car, partial);
644
1504
  if (
645
1505
  partial.x != null ||
@@ -654,9 +1514,21 @@ export class F1RaceSimulation {
654
1514
  }
655
1515
  car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
656
1516
  car.heading = normalizeAngle(car.heading);
657
- car.trackState = nearestTrackState(this.track, car);
658
- const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
659
- 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
+ }
660
1532
  car.progress = car.trackState.distance;
661
1533
  car.lap = this.computeLap(car.raceDistance);
662
1534
  car.drsZoneId = null;
@@ -665,6 +1537,7 @@ export class F1RaceSimulation {
665
1537
  car.drsEligible = false;
666
1538
  car.drsDetection = {};
667
1539
  resetTimingHistory(car, this.time);
1540
+ resetTimingLineCrossings(car, this.time);
668
1541
  resetLapTelemetry(car, this.time, this.track, this.totalLaps);
669
1542
  this.recalculateRaceState({ updateDrs: false });
670
1543
  this.evaluateRaceFinish();
@@ -681,8 +1554,712 @@ export class F1RaceSimulation {
681
1554
  if (car) car.manualControls = null;
682
1555
  }
683
1556
 
684
- step(dt) {
685
- 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);
686
2263
  if (!Number.isFinite(delta) || delta <= 0) return;
687
2264
 
688
2265
  this.time += delta;
@@ -696,6 +2273,24 @@ export class F1RaceSimulation {
696
2273
  return;
697
2274
  }
698
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
+
699
2294
  this.updateSafetyCar(delta);
700
2295
 
701
2296
  const orderedCars = this.orderedCars();
@@ -705,6 +2300,7 @@ export class F1RaceSimulation {
705
2300
  car.previousY = car.y;
706
2301
  car.previousHeading = car.heading;
707
2302
  car.previousProgress = car.progress;
2303
+ if (this.advancePitStopCar(car, delta)) return;
708
2304
  const controls = decideDriverControls({
709
2305
  car,
710
2306
  orderIndex: index,
@@ -717,11 +2313,169 @@ export class F1RaceSimulation {
717
2313
 
718
2314
  this.resolveCollisions();
719
2315
  this.recalculateRaceState();
720
- 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
+ });
721
2473
  }
722
2474
 
723
2475
  snapshot() {
724
2476
  const ordered = this.orderedCars();
2477
+ const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
2478
+ const penaltyStats = this.getPenaltyStatsByDriver();
725
2479
  return {
726
2480
  time: this.time,
727
2481
  world: WORLD,
@@ -729,6 +2483,9 @@ export class F1RaceSimulation {
729
2483
  totalLaps: this.totalLaps,
730
2484
  raceControl: {
731
2485
  mode: this.raceControl.mode,
2486
+ redFlag: Boolean(this.raceControl.redFlag),
2487
+ pitLaneOpen: pitLaneStatus.open,
2488
+ pitLaneStatus,
732
2489
  finished: this.raceControl.finished,
733
2490
  finishedAt: this.raceControl.finishedAt,
734
2491
  winner: this.getRaceWinnerSnapshot(),
@@ -739,23 +2496,99 @@ export class F1RaceSimulation {
739
2496
  (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
740
2497
  },
741
2498
  },
2499
+ pitLaneStatus,
742
2500
  safetyCar: { ...this.safetyCar },
743
2501
  rules: this.rules,
744
2502
  events: [...this.events],
745
- 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),
746
2530
  };
747
2531
  }
748
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
+
749
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
+
750
2587
  if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
751
- const byId = new Map(this.cars.map((car) => [car.id, car]));
752
2588
  return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
753
2589
  }
754
2590
 
755
- return [...this.cars].sort((a, b) => {
756
- const delta = b.raceDistance - a.raceDistance;
757
- return delta === 0 ? a.index - b.index : delta;
758
- });
2591
+ return sortLive(this.cars);
759
2592
  }
760
2593
 
761
2594
  driverRaceContext(orderedCars = this.orderedCars()) {
@@ -818,9 +2651,10 @@ export class F1RaceSimulation {
818
2651
  const wasGridLocked = car.gridLocked;
819
2652
  car.gridLocked = false;
820
2653
  const state = nearestTrackState(this.track, car, car.gridDistance);
821
- car.trackState = state;
822
2654
  car.progress = state.distance;
2655
+ applyWheelSurfaceState(car, this.track);
823
2656
  if (wasGridLocked) car.raceDistance = car.gridDistance;
2657
+ if (wasGridLocked) car.desiredOffset = 0;
824
2658
  car.previousX = car.x;
825
2659
  car.previousY = car.y;
826
2660
  car.previousHeading = car.heading;
@@ -846,7 +2680,7 @@ export class F1RaceSimulation {
846
2680
  car.turnRadius = Infinity;
847
2681
  car.progress = gridPoint.distance;
848
2682
  car.raceDistance = car.gridDistance;
849
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
2683
+ applyWheelSurfaceState(car, this.track);
850
2684
  });
851
2685
  }
852
2686
 
@@ -854,7 +2688,9 @@ export class F1RaceSimulation {
854
2688
  if (!this.safetyCar.deployed) return;
855
2689
  const leader = this.orderedCars()[0];
856
2690
  const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
857
- 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;
858
2694
  this.moveSafetyCarTo(progress);
859
2695
  }
860
2696
 
@@ -871,10 +2707,14 @@ export class F1RaceSimulation {
871
2707
 
872
2708
  applyRunoffResponse(car) {
873
2709
  const state = nearestTrackState(this.track, car, car.progress);
2710
+ if (state.inPitLane) {
2711
+ applyWheelSurfaceState(car, this.track, { centerState: state });
2712
+ return;
2713
+ }
874
2714
  const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
875
2715
  const overshoot = Math.abs(state.signedOffset) - signedLimit;
876
2716
  if (overshoot <= 0) {
877
- car.trackState = state;
2717
+ applyWheelSurfaceState(car, this.track, { centerState: state });
878
2718
  return;
879
2719
  }
880
2720
 
@@ -883,7 +2723,7 @@ export class F1RaceSimulation {
883
2723
  car.y -= state.normalY * side * overshoot;
884
2724
  car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
885
2725
  car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
886
- car.trackState = nearestTrackState(this.track, car, state.distance);
2726
+ applyWheelSurfaceState(car, this.track);
887
2727
  }
888
2728
 
889
2729
  recalculateRaceState({ updateDrs = true } = {}) {
@@ -891,7 +2731,7 @@ export class F1RaceSimulation {
891
2731
  const previousRaceDistance = car.raceDistance;
892
2732
  if (car.gridLocked) {
893
2733
  const gridPoint = pointAt(this.track, car.gridDistance);
894
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
2734
+ applyWheelSurfaceState(car, this.track);
895
2735
  car.progress = gridPoint.distance;
896
2736
  car.raceDistance = car.gridDistance;
897
2737
  car.lap = 1;
@@ -899,7 +2739,7 @@ export class F1RaceSimulation {
899
2739
  return;
900
2740
  }
901
2741
 
902
- car.trackState = nearestTrackState(this.track, car, car.progress);
2742
+ applyWheelSurfaceState(car, this.track);
903
2743
  const previousProgress = car.progress ?? car.trackState.distance;
904
2744
  const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
905
2745
  car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
@@ -909,20 +2749,41 @@ export class F1RaceSimulation {
909
2749
  });
910
2750
 
911
2751
  updateSectorPerformance(this.cars);
912
- 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
+ });
913
2757
 
914
2758
  const ordered = this.orderedCars();
2759
+ const leader = ordered[0];
915
2760
  ordered.forEach((car, index) => {
916
2761
  const ahead = ordered[index - 1];
2762
+ const drsReference = this.getDrsReferenceCar(car);
917
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);
918
2767
  car.rank = index + 1;
919
2768
  car.gapAhead = gap;
920
- 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;
921
2775
  car.intervalAheadSeconds = car.gapAheadSeconds;
922
- car.leaderGapSeconds = ahead ? ahead.leaderGapSeconds + car.gapAheadSeconds : 0;
923
- 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;
924
2780
  car.aggression = this.computeAggression(car, index);
925
- 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));
926
2787
  });
927
2788
  this.evaluateRaceFinish();
928
2789
  }
@@ -938,7 +2799,8 @@ export class F1RaceSimulation {
938
2799
  newlyFinished.forEach((car) => {
939
2800
  car.finished = true;
940
2801
  car.finishTime = this.time;
941
- car.classifiedRank = this.raceControl.finishOrder.length + 1;
2802
+ car.finishRank = this.raceControl.finishOrder.length + 1;
2803
+ car.classifiedRank = car.finishRank;
942
2804
  this.raceControl.finishOrder.push(car.id);
943
2805
  if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
944
2806
  car.drsActive = false;
@@ -950,14 +2812,17 @@ export class F1RaceSimulation {
950
2812
  type: 'car-finish',
951
2813
  at: this.time,
952
2814
  carId: car.id,
953
- rank: car.classifiedRank,
2815
+ rank: car.finishRank,
954
2816
  winnerId: this.raceControl.winnerId,
955
2817
  });
2818
+ this.reviewTireRequirement(car);
956
2819
  });
957
2820
 
958
2821
  if (!this.cars.every((car) => car.finished)) return;
959
2822
 
2823
+ this.applyOutstandingServicePenalties();
960
2824
  const classification = this.buildClassificationFromFinishOrder();
2825
+ this.raceControl.winnerId = classification[0]?.id ?? this.raceControl.winnerId;
961
2826
  this.raceControl.mode = 'safety-car';
962
2827
  this.raceControl.finished = true;
963
2828
  this.raceControl.finishedAt = this.time;
@@ -987,32 +2852,88 @@ export class F1RaceSimulation {
987
2852
  });
988
2853
  }
989
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
+
990
2872
  buildClassificationFromFinishOrder() {
991
2873
  const byId = new Map(this.cars.map((car) => [car.id, car]));
992
- const ordered = this.raceControl.finishOrder
993
- .map((id) => byId.get(id))
994
- .filter(Boolean);
995
- 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);
996
2886
  }
997
2887
 
998
- 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()) {
999
2907
  const leaderDistance = ordered[0]?.raceDistance ?? 0;
1000
- return ordered.map((car, index) => ({
1001
- id: car.id,
1002
- code: car.code,
1003
- timingCode: car.timingCode,
1004
- name: car.name,
1005
- rank: index + 1,
1006
- raceDistance: car.raceDistance,
1007
- distanceMeters: simUnitsToMeters(car.raceDistance),
1008
- lap: this.computeLap(car.raceDistance),
1009
- lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
1010
- gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
1011
- gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
1012
- intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
1013
- finished: car.raceDistance >= this.finishDistance,
1014
- finishTime: car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null),
1015
- }));
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
+ });
1016
2937
  }
1017
2938
 
1018
2939
  getRaceWinnerSnapshot() {
@@ -1020,10 +2941,21 @@ export class F1RaceSimulation {
1020
2941
  const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
1021
2942
  if (!car) return null;
1022
2943
  const rank = car.classifiedRank ?? car.rank ?? 1;
1023
- 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;
1024
2956
  }
1025
2957
 
1026
- updateDrsLatch(car, ahead, orderIndex) {
2958
+ updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
1027
2959
  if (this.safetyCar.deployed || car.finished) {
1028
2960
  car.drsEligible = false;
1029
2961
  car.drsActive = false;
@@ -1051,7 +2983,7 @@ export class F1RaceSimulation {
1051
2983
  const crossing = recordDrsDetection(car, crossedZone.id, this.time);
1052
2984
  const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
1053
2985
  car.drsZoneEnabled = Boolean(
1054
- orderIndex > 0 &&
2986
+ hasReference &&
1055
2987
  aheadCrossing &&
1056
2988
  aheadCrossing.passage === crossing.passage &&
1057
2989
  crossing.time >= aheadCrossing.time &&
@@ -1072,36 +3004,73 @@ export class F1RaceSimulation {
1072
3004
  const reportedContacts = new Set();
1073
3005
 
1074
3006
  for (let pass = 0; pass < 3; pass += 1) {
1075
- for (let i = 0; i < this.cars.length; i += 1) {
1076
- for (let j = i + 1; j < this.cars.length; j += 1) {
1077
- const first = this.cars[i];
1078
- const second = this.cars[j];
1079
- const collision = detectObbCollision(first, second) ?? detectLongitudinalCollision(first, second);
1080
- if (!collision) continue;
1081
-
1082
- const correction = Math.min(
1083
- collision.depth / 2 + (collision.longitudinal ? 0.35 : 0.65),
1084
- MAX_COLLISION_CORRECTION,
1085
- );
1086
- first.x -= collision.axis.x * correction;
1087
- first.y -= collision.axis.y * correction;
1088
- second.x += collision.axis.x * correction;
1089
- 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
+ }
1090
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 {
1091
3041
  this.applyContactVelocityResponse(first, second, collision.axis);
3042
+ }
1092
3043
 
1093
- const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
1094
- const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
1095
- first.heading = normalizeAngle(first.heading - collision.axis.y * yawNudge);
1096
- second.heading = normalizeAngle(second.heading + collision.axis.y * yawNudge);
1097
- first.contactCooldown = 1;
1098
- second.contactCooldown = 1;
1099
-
1100
- const contactKey = `${first.id}:${second.id}`;
1101
- if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
1102
- reportedContacts.add(contactKey);
1103
- this.events.unshift({ type: 'contact', at: this.time, carId: first.id, otherCarId: second.id });
1104
- }
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);
1105
3074
  }
1106
3075
  }
1107
3076
  }