@inventure71/paddockjs 1.0.0 → 2.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 (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,1544 +1,175 @@
1
- import {
2
- buildTrackModel,
3
- createProceduralTrack,
4
- nearestTrackState,
5
- offsetTrackPoint,
6
- pointAt,
7
- TRACK,
8
- WORLD,
9
- } from './trackModel.js';
10
- import { buildDriverPersonality, decideDriverControls } from './driverController.js';
11
- import { buildCollisionCandidatePairs, detectVehicleCollision } from './collisionGeometry.js';
12
- import { calculateCollisionPenalties } from './rules/collisionSteward.js';
13
1
  import {
14
2
  applyUnservedServicePenalty,
15
3
  cancelPenaltyRecord,
16
4
  createPenaltyEvent,
17
5
  createPenaltyRecord,
18
- getPitServicePenaltySeconds,
19
- isPenaltyActive,
20
- isPenaltyPitServiceable,
21
- serializePenalty,
22
- servePitPenaltyRecord,
23
6
  servePenaltyRecord,
24
7
  } 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';
29
- import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
30
- import { kphToSimSpeed, metersToSimUnits, simSpeedToKph, simUnitsToMeters } from './units.js';
31
- import { integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
32
- import { applyWheelSurfaceState } from './wheelSurface.js';
33
-
34
- const DEFAULT_TOTAL_LAPS = 10;
8
+ import { createPenaltyCancelledEvent, createPenaltyServedEvent } from './rules/penaltyEvents.js';
9
+ import { buildPenaltyStatsByDriver, getPenaltyStats } from './rules/penaltyStats.js';
10
+ import {
11
+ reviewCollisionForSimulation,
12
+ reviewPitLaneSpeedingForSimulation,
13
+ reviewTireRequirementForSimulation,
14
+ reviewTrackLimitsForSimulation,
15
+ } from './rules/rulesReview.js';
16
+ import { DEFAULT_RULES } from './rulesConfig.js';
17
+ import { applyExternalCarState } from './vehicle/vehicleState.js';
18
+ import {
19
+ applyContactVelocityResponse as applyContactVelocityResponseForSimulation,
20
+ resolveCollisionsForSimulation,
21
+ } from './vehicle/contactResolution.js';
22
+ import { applyRunoffResponseForSimulation } from './vehicle/runoffResponse.js';
23
+ import { resetLapTelemetry, resetTimingHistory, resetTimingLineCrossings } from './timing/raceTiming.js';
24
+ import { updateDrsLatch as updateDrsLatchState } from './timing/drsTiming.js';
25
+ import {
26
+ PIT_INTENT_NONE,
27
+ setPitIntentForSimulation,
28
+ } from './pit/pitIntent.js';
29
+ import { assignPitLaneTeams as assignPitLaneTeamsState, initializePitStops as initializePitStopsState } from './pit/pitState.js';
30
+ import {
31
+ applyPitRoutePosition as applyPitRoutePositionForSimulation,
32
+ beginPitPenaltyService as beginPitPenaltyServiceForSimulation,
33
+ beginPitQueue as beginPitQueueForSimulation,
34
+ beginPitService as beginPitServiceForSimulation,
35
+ beginTireService as beginTireServiceForSimulation,
36
+ calculatePitServiceProfile as calculatePitServiceProfileForSimulation,
37
+ completePitPenaltyService as completePitPenaltyServiceForSimulation,
38
+ completePitService as completePitServiceForSimulation,
39
+ finishPitExit as finishPitExitForSimulation,
40
+ getPitBoxRaceDistance as getPitBoxRaceDistanceForSimulation,
41
+ getPitServicePenalties as getPitServicePenaltiesForSimulation,
42
+ getPitStopBox as getPitStopBoxForSimulation,
43
+ isPitServiceAreaOccupied as isPitServiceAreaOccupiedForSimulation,
44
+ isPitServiceBusy as isPitServiceBusyForSimulation,
45
+ isPitServiceQueueOccupied as isPitServiceQueueOccupiedForSimulation,
46
+ releasePitQueue as releasePitQueueForSimulation,
47
+ } from './pit/pitService.js';
48
+ import {
49
+ advancePitStopCar as advancePitStopCarForSimulation,
50
+ canStartPitStop as canStartPitStopForSimulation,
51
+ isCarInActivePitStop as isCarInActivePitStopForSimulation,
52
+ isPitLaneOpenForStops as isPitLaneOpenForStopsForSimulation,
53
+ pitEntryLateThresholdDistance as pitEntryLateThresholdDistanceForSimulation,
54
+ rearmCompletedPitStop as rearmCompletedPitStopForSimulation,
55
+ schedulePitStopAtNextEntry as schedulePitStopAtNextEntryForSimulation,
56
+ shouldStartPitStop as shouldStartPitStopForSimulation,
57
+ startPitStop as startPitStopForSimulation,
58
+ updateAutomaticPitIntent as updateAutomaticPitIntentForSimulation,
59
+ } from './pit/pitFlow.js';
60
+ import { nearestDistanceOnRoute } from './pit/pitRouting.js';
61
+ import {
62
+ moveSafetyCarTo as moveSafetyCarToState,
63
+ setRedFlagState,
64
+ setSafetyCarState,
65
+ updateSafetyCarState,
66
+ } from './race/safetyCar.js';
67
+ import {
68
+ holdGridCars as holdGridCarsForSimulation,
69
+ releaseRaceStart as releaseRaceStartForSimulation,
70
+ setPitLaneOpenState,
71
+ updateStartSequence as updateStartSequenceForSimulation,
72
+ } from './race/raceLifecycle.js';
73
+ import {
74
+ applyClassificationConsequences as applyClassificationConsequencesState,
75
+ applyOutstandingServicePenalties as applyOutstandingServicePenaltiesState,
76
+ buildClassification as buildClassificationState,
77
+ buildClassificationFromFinishOrder as buildClassificationFromFinishOrderState,
78
+ } from './race/classification.js';
79
+ import {
80
+ computeAggressionForSimulation,
81
+ driverRaceContextForSimulation,
82
+ getDrsReferenceCarForSimulation,
83
+ orderedCarsForSimulation,
84
+ } from './race/raceOrder.js';
85
+ import {
86
+ computeLapForDistance,
87
+ finishDistanceForRace,
88
+ progressDelta,
89
+ } from './race/raceDistance.js';
90
+ import { recalculateRaceStateForSimulation } from './race/raceProgress.js';
91
+ import { evaluateRaceFinishForSimulation } from './race/raceFinish.js';
92
+ import { applyGridDropForSimulation } from './race/gridPenalties.js';
93
+ import { initializeRaceSimulation, normalizePitIntentForRace } from './race/raceSetup.js';
94
+ import { runRaceStep } from './race/raceStep.js';
95
+ import {
96
+ createVehicleSnapshotDependencies,
97
+ getRaceWinnerSnapshot as getRaceWinnerSnapshotForSimulation,
98
+ snapshotRace,
99
+ snapshotRaceObservation,
100
+ snapshotRaceRender,
101
+ snapshotRaceTraining,
102
+ } from './snapshots/raceSnapshots.js';
103
+ import {
104
+ disableWarmupOptions,
105
+ runWarmupWithGuard,
106
+ withWarmupSurfaceOptions,
107
+ } from './warmup/runtimeWarmup.js';
108
+
35
109
  export const FIXED_STEP = 1 / 60;
36
- const MIN_TOTAL_LAPS = 1;
37
- const MAX_COLLISION_CORRECTION = 4.5;
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);
43
- const TIMING_HISTORY_WINDOW_SECONDS = 18;
44
- const TIMING_HISTORY_MAX_SAMPLES = 720;
45
- const TIMING_LINE_TARGET_SPACING_METERS = 175;
46
- const TIMING_LINE_HISTORY_LAPS = 3;
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
110
 
70
111
  export { DEFAULT_RULES };
71
112
 
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
- }
79
-
80
- function isPitPositionControlledCar(car) {
81
- const status = car?.pitStop?.status;
82
- return status === 'entering' || status === 'queued' || status === 'servicing' || status === 'exiting';
83
- }
84
-
85
- function wrapProgress(value, length) {
86
- return wrapDistance(value, length);
87
- }
88
-
89
- function distanceForward(from, to, length) {
90
- return wrapProgress(to - from, length);
91
- }
92
-
93
- function crossesDistance(previous, current, target, length) {
94
- const travelled = distanceForward(previous, current, length);
95
- if (travelled <= 0 || travelled > length / 2) return false;
96
- const targetOffset = distanceForward(previous, target, length);
97
- return targetOffset > 0 && targetOffset <= travelled + 0.001;
98
- }
99
-
100
- function isProgressInZone(track, progress, zone) {
101
- const wrapped = wrapProgress(progress, track.length);
102
- const start = wrapProgress(zone.start, track.length);
103
- const end = wrapProgress(zone.end, track.length);
104
- if (zone.end - zone.start >= track.length) return true;
105
- return end >= start
106
- ? wrapped >= start && wrapped <= end
107
- : wrapped >= start || wrapped <= end;
108
- }
109
-
110
- function createEmptySectorTimes() {
111
- return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
112
- }
113
-
114
- function createEmptySectorPerformance() {
115
- return {
116
- current: createEmptySectorTimes(),
117
- last: createEmptySectorTimes(),
118
- best: createEmptySectorTimes(),
119
- };
120
- }
121
-
122
- function getSectorLength(track) {
123
- return track.length / TELEMETRY_SECTOR_COUNT;
124
- }
125
-
126
- function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
127
- const positiveDistance = Math.max(0, raceDistance ?? 0);
128
- const lapIndex = Math.min(
129
- Math.floor(positiveDistance / track.length),
130
- Math.max(0, totalLaps - 1),
131
- );
132
- const lapProgress = positiveDistance >= track.length * totalLaps
133
- ? track.length
134
- : positiveDistance - lapIndex * track.length;
135
- const sectorLength = getSectorLength(track);
136
- const sectorIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
137
- const sectorStart = sectorIndex * sectorLength;
138
-
139
- return {
140
- completedLaps: Math.floor(positiveDistance / track.length),
141
- currentLap: Math.max(1, lapIndex + 1),
142
- currentSector: sectorIndex + 1,
143
- currentSectorProgress: clamp((lapProgress - sectorStart) / sectorLength, 0, 1),
144
- };
145
- }
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
-
156
- function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
157
- const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
158
- return {
159
- ...position,
160
- currentLapStartedAt: currentTime,
161
- currentSectorStartedAt: currentTime,
162
- lastUpdatedAt: currentTime,
163
- currentLapTime: 0,
164
- currentSectorElapsed: 0,
165
- currentSectors: createEmptySectorTimes(),
166
- sectorProgress: createSectorProgress(position),
167
- liveSectors: createEmptySectorTimes(),
168
- lastLapTime: null,
169
- bestLapTime: null,
170
- lastSectors: createEmptySectorTimes(),
171
- bestSectors: createEmptySectorTimes(),
172
- sectorPerformance: createEmptySectorPerformance(),
173
- };
174
- }
175
-
176
- function resetLapTelemetry(car, currentTime, track, totalLaps) {
177
- car.lapTelemetry = createLapTelemetry(track, currentTime, car.raceDistance, totalLaps);
178
- }
179
-
180
- function finiteOrNull(value) {
181
- return Number.isFinite(value) ? value : null;
182
- }
183
-
184
- function serializeSectorTimes(values) {
185
- return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
186
- }
187
-
188
- function serializeLapTelemetry(telemetry) {
189
- return {
190
- currentLap: telemetry.currentLap,
191
- currentSector: telemetry.currentSector,
192
- currentLapTime: finiteOrNull(telemetry.currentLapTime),
193
- currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
194
- currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
195
- currentSectors: serializeSectorTimes(telemetry.currentSectors),
196
- sectorProgress: serializeSectorTimes(telemetry.sectorProgress),
197
- liveSectors: serializeSectorTimes(telemetry.liveSectors),
198
- lastLapTime: finiteOrNull(telemetry.lastLapTime),
199
- bestLapTime: finiteOrNull(telemetry.bestLapTime),
200
- lastSectors: serializeSectorTimes(telemetry.lastSectors),
201
- bestSectors: serializeSectorTimes(telemetry.bestSectors),
202
- sectorPerformance: {
203
- current: serializeSectorPerformance(telemetry.sectorPerformance?.current),
204
- last: serializeSectorPerformance(telemetry.sectorPerformance?.last),
205
- best: serializeSectorPerformance(telemetry.sectorPerformance?.best),
206
- },
207
- completedLaps: telemetry.completedLaps,
208
- };
209
- }
210
-
211
- function serializeSectorPerformance(values) {
212
- return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
213
- }
214
-
215
- function updateBestSector(telemetry, sectorIndex, sectorTime) {
216
- const previousBest = telemetry.bestSectors[sectorIndex];
217
- if (!Number.isFinite(previousBest) || sectorTime < previousBest) {
218
- telemetry.bestSectors[sectorIndex] = sectorTime;
219
- }
220
- }
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
-
241
- function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
242
- const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
243
- telemetry.currentLap = position.currentLap;
244
- telemetry.currentSector = position.currentSector;
245
- telemetry.currentSectorProgress = position.currentSectorProgress;
246
- clearFutureSectorTelemetry(telemetry);
247
- telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
248
- telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
249
- telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
250
- telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
251
- syncLiveSectorTelemetry(telemetry, track);
252
- }
253
-
254
- function classifySectorPerformance(value, personalBest, overallBest) {
255
- if (!Number.isFinite(value)) return null;
256
- if (Number.isFinite(overallBest) && Math.abs(value - overallBest) <= 1e-6) return 'overall-best';
257
- if (Number.isFinite(personalBest) && Math.abs(value - personalBest) <= 1e-6) return 'personal-best';
258
- return 'slower';
259
- }
260
-
261
- function updateSectorPerformance(cars) {
262
- const overallBestSectors = createEmptySectorTimes();
263
- cars.forEach((car) => {
264
- car.lapTelemetry?.bestSectors?.forEach((time, index) => {
265
- if (!Number.isFinite(time)) return;
266
- const previousBest = overallBestSectors[index];
267
- if (!Number.isFinite(previousBest) || time < previousBest) overallBestSectors[index] = time;
268
- });
269
- });
270
-
271
- cars.forEach((car) => {
272
- if (!car.lapTelemetry) return;
273
- const telemetry = car.lapTelemetry;
274
- telemetry.sectorPerformance = {
275
- current: telemetry.currentSectors.map((time, index) => (
276
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
277
- )),
278
- last: telemetry.lastSectors.map((time, index) => (
279
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
280
- )),
281
- best: telemetry.bestSectors.map((time, index) => (
282
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
283
- )),
284
- };
285
- });
286
- }
287
-
288
- function updateLapTelemetry(car, previousRaceDistance, currentTime, track, totalLaps) {
289
- if (!car.lapTelemetry) resetLapTelemetry(car, currentTime, track, totalLaps);
290
-
291
- const telemetry = car.lapTelemetry;
292
- const currentRaceDistance = car.raceDistance ?? 0;
293
- const previousDistance = Math.max(0, previousRaceDistance ?? currentRaceDistance);
294
- const currentDistance = Math.max(0, currentRaceDistance);
295
- const travelled = currentDistance - previousDistance;
296
- const previousUpdateTime = Number.isFinite(telemetry.lastUpdatedAt) ? telemetry.lastUpdatedAt : currentTime;
297
- const elapsedTime = Math.max(0, currentTime - previousUpdateTime);
298
-
299
- if (!Number.isFinite(previousDistance) || !Number.isFinite(currentDistance) || travelled < -1e-3 || travelled > track.length / 2) {
300
- resetLapTelemetry(car, currentTime, track, totalLaps);
301
- return;
302
- }
303
-
304
- const sectorLength = getSectorLength(track);
305
- if (travelled > 1e-6) {
306
- const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
307
- const lastBoundary = Math.floor(currentDistance / sectorLength);
308
-
309
- for (let boundary = firstBoundary; boundary <= lastBoundary; boundary += 1) {
310
- const boundaryDistance = boundary * sectorLength;
311
- if (boundaryDistance <= previousDistance + 1e-3 || boundaryDistance > currentDistance + 1e-3) continue;
312
-
313
- const boundaryRatio = clamp((boundaryDistance - previousDistance) / travelled, 0, 1);
314
- const crossingTime = previousUpdateTime + boundaryRatio * elapsedTime;
315
- const sectorIndex = (boundary - 1) % TELEMETRY_SECTOR_COUNT;
316
- const sectorTime = Math.max(0, crossingTime - telemetry.currentSectorStartedAt);
317
-
318
- telemetry.currentSectors[sectorIndex] = sectorTime;
319
- updateBestSector(telemetry, sectorIndex, sectorTime);
320
-
321
- if (sectorIndex === TELEMETRY_SECTOR_COUNT - 1) {
322
- const lapTime = Math.max(0, crossingTime - telemetry.currentLapStartedAt);
323
- telemetry.lastLapTime = lapTime;
324
- telemetry.bestLapTime = Number.isFinite(telemetry.bestLapTime)
325
- ? Math.min(telemetry.bestLapTime, lapTime)
326
- : lapTime;
327
- telemetry.lastSectors = serializeSectorTimes(telemetry.currentSectors);
328
- telemetry.currentSectors = createEmptySectorTimes();
329
- telemetry.currentLapStartedAt = crossingTime;
330
- telemetry.completedLaps = Math.max(telemetry.completedLaps + 1, Math.min(Math.floor(boundaryDistance / track.length), totalLaps));
331
- }
332
-
333
- telemetry.currentSectorStartedAt = crossingTime;
334
- }
335
- }
336
-
337
- syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps);
338
- telemetry.lastUpdatedAt = currentTime;
339
- }
340
-
341
- function createCar(driver, index, random, track, { standingStart = false } = {}) {
342
- const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart });
343
- const start = pointAt(track, gridDistance);
344
- const position = offsetTrackPoint(start, gridOffset);
345
- const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
346
- const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
347
- const personality = buildDriverPersonality(driver, index, racecraft, random);
348
- const launchSpeed = kphToSimSpeed(Math.max(70, 84 - index * 0.55 + pace * 4));
349
- const vehicle = driver.vehicle ?? {};
350
-
351
- return {
352
- id: driver.id ?? `car-${index + 1}`,
353
- code: driver.code ?? `C${index + 1}`,
354
- timingCode: driver.timingCode ?? driver.code ?? `C${index + 1}`,
355
- driverNumber: driver.driverNumber ?? index + 1,
356
- icon: driver.icon ?? String(index + 1).padStart(2, '0'),
357
- raceName: driver.raceName ?? driver.code ?? `CAR${index + 1}`,
358
- name: driver.name ?? `Car ${index + 1}`,
359
- color: driver.color ?? '#e10600',
360
- team: driver.team ? { ...driver.team } : null,
361
- tire: driver.tire ?? ['M', 'H', 'S'][index % 3],
362
- index,
363
- x: position.x,
364
- y: position.y,
365
- previousX: position.x,
366
- previousY: position.y,
367
- heading: start.heading,
368
- previousHeading: start.heading,
369
- steeringAngle: 0,
370
- yawRate: 0,
371
- turnRadius: Infinity,
372
- speed: standingStart ? 0 : launchSpeed,
373
- throttle: 0,
374
- brake: 0,
375
- vehicleId: vehicle.id ?? null,
376
- vehicleName: vehicle.name ?? null,
377
- vehicleRatings: vehicle.ratings ? { ...vehicle.ratings } : null,
378
- mass: vehicle.mass ?? 798 + seededRange(random, -5, 5),
379
- powerNewtons: vehicle.powerNewtons ?? 43000 * pace,
380
- brakeNewtons: vehicle.brakeNewtons ?? 59000,
381
- dragCoefficient: vehicle.dragCoefficient ?? 0.33 + seededRange(random, -0.026, 0.026),
382
- downforceCoefficient: vehicle.downforceCoefficient ?? 6.1 + seededRange(random, -0.18, 0.18),
383
- tireGrip: vehicle.tireGrip ?? 2.22 + racecraft * 0.28 + seededRange(random, -0.03, 0.03),
384
- tireCare: vehicle.tireCare ?? 1,
385
- pace,
386
- racecraft,
387
- personality,
388
- aggression: personality.baseAggression,
389
- gridLocked: standingStart,
390
- gridDistance,
391
- gridOffset,
392
- desiredOffset: gridOffset,
393
- progress: start.distance,
394
- raceDistance: gridDistance,
395
- lap: 1,
396
- rank: index + 1,
397
- gapAhead: Infinity,
398
- gapAheadSeconds: Infinity,
399
- leaderGapSeconds: 0,
400
- intervalAheadSeconds: Infinity,
401
- gapAheadLaps: 0,
402
- intervalAheadLaps: 0,
403
- leaderGapLaps: 0,
404
- drsEligible: false,
405
- drsActive: false,
406
- drsZoneId: null,
407
- drsZoneEnabled: false,
408
- timingHistory: [],
409
- lapTelemetry: createLapTelemetry(track, 0, gridDistance),
410
- drsDetection: {},
411
- canAttack: true,
412
- trackState: start,
413
- contactCooldown: 0,
414
- tireEnergy: 100,
415
- usedTireCompounds: [driver.tire ?? ['M', 'H', 'S'][index % 3]],
416
- automaticPitIntentEnabled: true,
417
- };
418
- }
419
-
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
- }
425
-
426
- function forwardVector(car) {
427
- return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
428
- }
429
-
430
- function velocityVector(car) {
431
- const forward = forwardVector(car);
432
- return { x: forward.x * car.speed, y: forward.y * car.speed };
433
- }
434
-
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
- }
456
-
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
- };
470
-
471
- return {
472
- ...collision,
473
- impactSpeed: Math.max(0, dot(relativeVelocity, directionBehindToAhead)),
474
- aheadDriverId: ahead.id,
475
- atFaultDriverId: behind.id,
476
- };
477
- }
478
-
479
- function dot(a, b) {
480
- return a.x * b.x + a.y * b.y;
481
- }
482
-
483
- function normalizeVector(vector) {
484
- const length = Math.hypot(vector.x, vector.y) || 1;
485
- return { x: vector.x / length, y: vector.y / length };
486
- }
487
-
488
- function progressDelta(a, b, trackLength) {
489
- let delta = a - b;
490
- if (delta < -trackLength / 2) delta += trackLength;
491
- if (delta > trackLength / 2) delta -= trackLength;
492
- return delta;
493
- }
494
-
495
- function trimTimingHistory(history, currentTime) {
496
- const cutoff = currentTime - TIMING_HISTORY_WINDOW_SECONDS;
497
- while (history.length > 2 && (history[0].time < cutoff || history.length > TIMING_HISTORY_MAX_SAMPLES)) {
498
- history.shift();
499
- }
500
- }
501
-
502
- function resetTimingHistory(car, currentTime) {
503
- car.timingHistory = [{ time: currentTime, raceDistance: car.raceDistance }];
504
- }
505
-
506
- function recordTimingSample(car, currentTime) {
507
- if (!Number.isFinite(car.raceDistance)) return;
508
- if (!Array.isArray(car.timingHistory)) {
509
- resetTimingHistory(car, currentTime);
510
- return;
511
- }
512
-
513
- const previous = car.timingHistory[car.timingHistory.length - 1];
514
- if (
515
- previous &&
516
- Math.abs(previous.time - currentTime) <= 1e-6 &&
517
- Math.abs(previous.raceDistance - car.raceDistance) <= 1e-3
518
- ) {
519
- return;
520
- }
521
-
522
- car.timingHistory.push({ time: currentTime, raceDistance: car.raceDistance });
523
- trimTimingHistory(car.timingHistory, currentTime);
524
- }
525
-
526
- function interpolateTimeAtDistance(history, targetDistance) {
527
- if (!Array.isArray(history) || history.length < 2) return null;
528
-
529
- for (let index = history.length - 1; index > 0; index -= 1) {
530
- const current = history[index];
531
- const previous = history[index - 1];
532
- if (targetDistance < previous.raceDistance || targetDistance > current.raceDistance) continue;
533
-
534
- const coveredDistance = current.raceDistance - previous.raceDistance;
535
- if (coveredDistance <= 1e-6) return current.time;
536
- const ratio = (targetDistance - previous.raceDistance) / coveredDistance;
537
- return previous.time + (current.time - previous.time) * ratio;
538
- }
539
-
540
- return null;
541
- }
542
-
543
- function estimateGapAheadSeconds(ahead, car, currentTime, track) {
544
- if (!ahead) return Infinity;
545
-
546
- const timingLineGap = estimateTimingLineGapSeconds(ahead, car, currentTime, track);
547
- if (Number.isFinite(timingLineGap)) return timingLineGap;
548
-
549
- const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
550
- if (Number.isFinite(crossingTime)) {
551
- return Math.max(0, currentTime - crossingTime);
552
- }
553
-
554
- const speedReference = Math.max((ahead.speed + car.speed) * 0.5, 1);
555
- return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
556
- }
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
-
658
- function recordDrsDetection(car, zoneId, currentTime) {
659
- const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
660
- const next = { passage: previous.passage + 1, time: currentTime };
661
- car.drsDetection = {
662
- ...(car.drsDetection ?? {}),
663
- [zoneId]: next,
664
- };
665
- return next;
666
- }
667
-
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';
1065
- return {
1066
- id: car.id,
1067
- code: car.code,
1068
- timingCode: car.timingCode,
1069
- driverNumber: car.driverNumber,
1070
- icon: car.icon,
1071
- raceName: car.raceName,
1072
- name: car.name,
1073
- color: car.color,
1074
- team: car.team ? { ...car.team } : null,
1075
- tire: car.tire,
1076
- personality: { ...car.personality },
1077
- aggression: car.aggression,
1078
- aggressionPercent: Math.round((car.aggression ?? 0) * 100),
1079
- setup: {
1080
- vehicleId: car.vehicleId,
1081
- vehicleName: car.vehicleName,
1082
- vehicleRatings: car.vehicleRatings ? { ...car.vehicleRatings } : null,
1083
- maxSpeedKph: simSpeedToKph(VEHICLE_LIMITS.maxSpeed),
1084
- powerUnitKn: car.powerNewtons / 1000,
1085
- brakeSystemKn: car.brakeNewtons / 1000,
1086
- dragCoefficient: car.dragCoefficient,
1087
- downforceCoefficient: car.downforceCoefficient,
1088
- tireGrip: car.tireGrip,
1089
- tireCare: car.tireCare,
1090
- massKg: car.mass,
1091
- pace: car.pace,
1092
- racecraft: car.racecraft,
1093
- },
1094
- rank,
1095
- classifiedRank: car.classifiedRank ?? rank,
1096
- finishRank: car.finishRank ?? null,
1097
- status: raceStatus,
1098
- raceStatus,
1099
- wavedFlag: Boolean(car.finished),
1100
- finished: Boolean(car.finished),
1101
- finishTime,
1102
- penaltySeconds,
1103
- adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
1104
- previousX: car.previousX ?? car.x,
1105
- previousY: car.previousY ?? car.y,
1106
- x: car.x,
1107
- y: car.y,
1108
- previousHeading: car.previousHeading ?? car.heading,
1109
- heading: car.heading,
1110
- steeringAngle: car.steeringAngle,
1111
- yawRate: car.yawRate,
1112
- turnRadius: car.turnRadius,
1113
- speed: car.speed,
1114
- speedKph: simSpeedToKph(car.speed),
1115
- throttle: car.throttle,
1116
- brake: car.brake,
1117
- lateralAcceleration: car.lateralAcceleration,
1118
- progress: car.progress,
1119
- raceDistance: car.raceDistance,
1120
- distanceMeters: simUnitsToMeters(car.raceDistance),
1121
- lap: car.lap,
1122
- lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
1123
- gapAhead: car.gapAhead,
1124
- gapAheadMeters: Number.isFinite(car.gapAhead) ? simUnitsToMeters(car.gapAhead) : Infinity,
1125
- gapAheadSeconds: car.gapAheadSeconds,
1126
- intervalAheadSeconds: car.intervalAheadSeconds,
1127
- leaderGapSeconds: car.leaderGapSeconds,
1128
- gapAheadLaps: car.gapAheadLaps ?? 0,
1129
- intervalAheadLaps: car.intervalAheadLaps ?? car.gapAheadLaps ?? 0,
1130
- leaderGapLaps: car.leaderGapLaps ?? 0,
1131
- drsEligible: car.drsEligible,
1132
- drsActive: car.drsActive,
1133
- drsZoneId: car.drsZoneId,
1134
- drsZoneEnabled: car.drsZoneEnabled,
1135
- canAttack: car.canAttack,
1136
- signedOffset: car.trackState?.signedOffset ?? 0,
1137
- crossTrackError: car.trackState?.crossTrackError ?? 0,
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,
1144
- contactCooldown: car.contactCooldown,
1145
- tireEnergy: car.tireEnergy,
1146
- usedTireCompounds: [...(car.usedTireCompounds ?? [])],
1147
- pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
1148
- pitStop: serializePitStop(car.pitStop),
1149
- positionSource: 'integrated-vehicle',
1150
- };
1151
- }
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
-
1265
113
  export class F1RaceSimulation {
1266
- constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
1267
- this.seed = seed;
1268
- this.random = createMulberry32(seed);
1269
- const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
1270
- const builtTrack = buildTrackModel(trackDefinition);
1271
- this.track = {
1272
- ...builtTrack,
1273
- pitLane: clonePitLaneModel(builtTrack.pitLane),
1274
- };
1275
- this.track.timingLines = createTimingLines(this.track);
1276
- this.trackSeed = this.track.seed ?? trackSeed;
1277
- this.rules = normalizeRaceRules(rules);
1278
- this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
1279
- this.totalLaps = normalizeTotalLaps(totalLaps);
1280
- this.time = 0;
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
- };
1289
- this.raceControl = {
1290
- mode: this.rules.standingStart === false ? 'green' : 'pre-start',
1291
- frozenOrder: null,
1292
- redFlag: false,
1293
- pitLaneOpen: true,
1294
- finished: false,
1295
- finishedAt: null,
1296
- winnerId: null,
1297
- classification: [],
1298
- finishOrder: [],
1299
- start: {
1300
- lightCount: this.rules.startLightCount,
1301
- lightsLit: 0,
1302
- lightsOutAt: this.startLightsOutAt,
1303
- released: this.rules.standingStart === false,
1304
- releasedAt: this.rules.standingStart === false ? 0 : null,
1305
- },
1306
- };
1307
- const safetyCarStart = pointAt(this.track, this.rules.safetyCarLeadDistance);
1308
- this.safetyCar = {
1309
- deployed: false,
1310
- progress: this.rules.safetyCarLeadDistance,
1311
- speed: this.rules.safetyCarSpeed,
1312
- previousX: safetyCarStart.x,
1313
- previousY: safetyCarStart.y,
1314
- previousHeading: safetyCarStart.heading,
1315
- x: safetyCarStart.x,
1316
- y: safetyCarStart.y,
1317
- heading: safetyCarStart.heading,
1318
- };
1319
- this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
1320
- standingStart: this.raceControl.mode === 'pre-start',
1321
- }));
1322
- this.assignPitLaneTeams();
1323
- this.initializePitStops();
1324
- this.recalculateRaceState({ updateDrs: false });
1325
- this.cars.forEach((car) => resetTimingHistory(car, this.time));
1326
- this.cars.forEach((car) => resetTimingLineCrossings(car, this.time));
1327
- this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
114
+ constructor(options = {}) {
115
+ initializeRaceSimulation(this, options);
1328
116
  }
1329
117
 
1330
118
  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
- });
119
+ assignPitLaneTeamsState({ cars: this.cars, pitLane: this.track.pitLane });
1375
120
  }
1376
121
 
1377
122
  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
- };
123
+ initializePitStopsState({
124
+ cars: this.cars,
125
+ pitLane: this.track.pitLane,
126
+ pitStops: this.rules.modules?.pitStops,
127
+ totalLaps: this.totalLaps,
128
+ trackLength: this.track.length,
129
+ tireCompounds: this.rules.modules?.tireStrategy?.compounds,
130
+ PIT_INTENT_NONE,
1426
131
  });
1427
132
  }
1428
133
 
1429
134
  setSafetyCar(deployed) {
1430
- const next = Boolean(deployed);
1431
- if (this.raceControl.finished) return;
1432
- if (next && this.raceControl.mode === 'pre-start') return;
1433
- if (this.raceControl.redFlag) return;
1434
- if (next === this.safetyCar.deployed) return;
1435
- const ordered = this.orderedCars();
1436
- this.safetyCar.deployed = next;
1437
- this.raceControl.mode = next ? 'safety-car' : 'green';
1438
- this.raceControl.frozenOrder = next ? ordered.map((car) => car.id) : null;
1439
- if (next) {
1440
- const leader = ordered[0];
1441
- const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
1442
- if (this.safetyCar.progress < safetyCarProgress) {
1443
- this.moveSafetyCarTo(safetyCarProgress);
1444
- }
1445
- this.cars.forEach((car) => {
1446
- car.desiredOffset = 0;
1447
- car.drsActive = false;
1448
- car.drsEligible = false;
1449
- car.drsZoneId = null;
1450
- car.drsZoneEnabled = false;
1451
- });
1452
- }
1453
- this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
135
+ setSafetyCarState(this, deployed);
1454
136
  }
1455
137
 
1456
138
  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 });
139
+ setRedFlagState(this, deployed);
1490
140
  }
1491
141
 
1492
142
  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 });
143
+ setPitLaneOpenState(this, open);
1497
144
  }
1498
145
 
1499
146
  setCarState(id, partial) {
1500
- const car = this.cars.find((item) => item.id === id);
1501
- if (!car) return;
1502
- const hasExplicitRaceDistance = partial.raceDistance != null;
1503
- Object.assign(car, partial);
1504
- if (
1505
- partial.x != null ||
1506
- partial.y != null ||
1507
- partial.progress != null ||
1508
- partial.raceDistance != null
1509
- ) {
1510
- car.gridLocked = false;
1511
- if (this.raceControl.mode === 'pre-start' && this.cars.every((item) => !item.gridLocked)) {
1512
- this.releaseRaceStart();
1513
- }
1514
- }
1515
- car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
1516
- car.heading = normalizeAngle(car.heading);
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
- }
1532
- car.progress = car.trackState.distance;
1533
- car.lap = this.computeLap(car.raceDistance);
1534
- car.drsZoneId = null;
1535
- car.drsZoneEnabled = false;
1536
- car.drsActive = false;
1537
- car.drsEligible = false;
1538
- car.drsDetection = {};
1539
- resetTimingHistory(car, this.time);
1540
- resetTimingLineCrossings(car, this.time);
1541
- resetLapTelemetry(car, this.time, this.track, this.totalLaps);
147
+ this.setCarStates({ [id]: partial });
148
+ }
149
+
150
+ setCarStates(partialsById = {}) {
151
+ const context = {
152
+ cars: this.cars,
153
+ computeLap: (raceDistance) => this.computeLap(raceDistance),
154
+ nearestDistanceOnRoute,
155
+ progressDelta,
156
+ raceControl: this.raceControl,
157
+ releaseRaceStart: () => this.releaseRaceStart(),
158
+ resetLapTelemetry,
159
+ resetTimingHistory,
160
+ resetTimingLineCrossings,
161
+ time: this.time,
162
+ totalLaps: this.totalLaps,
163
+ track: this.track,
164
+ };
165
+ let applied = false;
166
+ Object.entries(partialsById).forEach(([id, partial]) => {
167
+ const car = this.cars.find((item) => item.id === id);
168
+ if (!car) return;
169
+ applyExternalCarState(car, partial, context);
170
+ applied = true;
171
+ });
172
+ if (!applied) return;
1542
173
  this.recalculateRaceState({ updateDrs: false });
1543
174
  this.evaluateRaceFinish();
1544
175
  }
@@ -1563,7 +194,7 @@ export class F1RaceSimulation {
1563
194
 
1564
195
  getPitIntent(id) {
1565
196
  const car = this.cars.find((item) => item.id === id);
1566
- return normalizePitIntent(car?.pitStop?.intent) ?? PIT_INTENT_NONE;
197
+ return normalizePitIntentForRace(car);
1567
198
  }
1568
199
 
1569
200
  getPitTargetCompound(id) {
@@ -1572,749 +203,115 @@ export class F1RaceSimulation {
1572
203
  }
1573
204
 
1574
205
  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;
206
+ return setPitIntentForSimulation(this, id, intent, targetCompound);
1609
207
  }
1610
208
 
1611
209
  isCarInActivePitStop(car) {
1612
- return Boolean(car.pitStop && car.pitStop.status !== 'pending' && car.pitStop.status !== 'completed');
210
+ return isCarInActivePitStopForSimulation(this, car);
1613
211
  }
1614
212
 
1615
213
  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;
214
+ return schedulePitStopAtNextEntryForSimulation(this, car, stop);
1632
215
  }
1633
216
 
1634
217
  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);
218
+ return rearmCompletedPitStopForSimulation(this, car, stop);
1650
219
  }
1651
220
 
1652
221
  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
- });
222
+ return canStartPitStopForSimulation(this, car);
1671
223
  }
1672
224
 
1673
225
  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));
226
+ return shouldStartPitStopForSimulation(this, car);
1686
227
  }
1687
228
 
1688
229
  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;
230
+ return updateAutomaticPitIntentForSimulation(this, car);
231
+ }
1697
232
 
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;
233
+ isPitLaneOpenForStops() {
234
+ return isPitLaneOpenForStopsForSimulation(this);
235
+ }
1702
236
 
1703
- stop.intent = nextIntent;
1704
- if (stop.status === 'completed') this.rearmCompletedPitStop(car, stop);
1705
- else this.schedulePitStopAtNextEntry(car, stop);
237
+ pitEntryLateThresholdDistance() {
238
+ return pitEntryLateThresholdDistanceForSimulation();
1706
239
  }
1707
240
 
1708
241
  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;
242
+ return getPitStopBoxForSimulation(this, stop);
1712
243
  }
1713
244
 
1714
245
  isPitServiceBusy(car, box) {
1715
- return this.cars.some((candidate) => (
1716
- candidate !== car &&
1717
- candidate.pitStop?.boxId === box?.id &&
1718
- this.isPitServiceAreaOccupied(candidate, box)
1719
- ));
246
+ return isPitServiceBusyForSimulation(this, car, box);
1720
247
  }
1721
248
 
1722
249
  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;
250
+ return isPitServiceAreaOccupiedForSimulation(this, candidate, box);
1730
251
  }
1731
252
 
1732
253
  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
- ));
254
+ return isPitServiceQueueOccupiedForSimulation(this, car, box);
1741
255
  }
1742
256
 
1743
257
  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;
258
+ return getPitBoxRaceDistanceForSimulation(this, stop, box);
1748
259
  }
1749
260
 
1750
261
  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;
262
+ return startPitStopForSimulation(this, car);
1809
263
  }
1810
264
 
1811
265
  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;
266
+ return beginPitQueueForSimulation(this, car, box);
1832
267
  }
1833
268
 
1834
269
  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;
270
+ return releasePitQueueForSimulation(this, car, box, { fromCurrent });
1858
271
  }
1859
272
 
1860
273
  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);
274
+ return beginPitServiceForSimulation(this, car, box);
1880
275
  }
1881
276
 
1882
277
  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;
278
+ return beginPitPenaltyServiceForSimulation(this, car);
1909
279
  }
1910
280
 
1911
281
  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;
282
+ return calculatePitServiceProfileForSimulation(this, car);
1951
283
  }
1952
284
 
1953
285
  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
- });
286
+ return beginTireServiceForSimulation(this, car);
1971
287
  }
1972
288
 
1973
289
  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);
290
+ return completePitPenaltyServiceForSimulation(this, car);
2001
291
  }
2002
292
 
2003
293
  getPitServicePenalties(driverId) {
2004
- return this.penalties.filter((penalty) => (
2005
- penalty.driverId === driverId && isPenaltyPitServiceable(penalty)
2006
- ));
294
+ return getPitServicePenaltiesForSimulation(this, driverId);
2007
295
  }
2008
296
 
2009
297
  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;
298
+ return completePitServiceForSimulation(this, car);
2042
299
  }
2043
300
 
2044
301
  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
- });
302
+ return finishPitExitForSimulation(this, car);
2068
303
  }
2069
304
 
2070
305
  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;
306
+ return applyPitRoutePositionForSimulation(this, car, delta);
2172
307
  }
2173
308
 
2174
309
  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;
310
+ return advancePitStopCarForSimulation(this, car, delta);
2259
311
  }
2260
312
 
2261
313
  step(dt) {
2262
- const delta = clamp(dt, 0, 1 / 20);
2263
- if (!Number.isFinite(delta) || delta <= 0) return;
2264
-
2265
- this.time += delta;
2266
- this.events = [];
2267
- this.updateStartSequence();
2268
- this.recalculateRaceState({ updateDrs: false });
2269
-
2270
- if (this.raceControl.mode === 'pre-start' && this.cars.every((car) => car.gridLocked)) {
2271
- this.holdGridCars();
2272
- this.recalculateRaceState({ updateDrs: false });
2273
- return;
2274
- }
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
-
2294
- this.updateSafetyCar(delta);
2295
-
2296
- const orderedCars = this.orderedCars();
2297
- const raceContext = this.driverRaceContext(orderedCars);
2298
- orderedCars.forEach((car, index) => {
2299
- car.previousX = car.x;
2300
- car.previousY = car.y;
2301
- car.previousHeading = car.heading;
2302
- car.previousProgress = car.progress;
2303
- if (this.advancePitStopCar(car, delta)) return;
2304
- const controls = decideDriverControls({
2305
- car,
2306
- orderIndex: index,
2307
- race: raceContext,
2308
- });
2309
- integrateVehiclePhysics(car, controls, delta);
2310
- this.applyRunoffResponse(car);
2311
- car.contactCooldown = Math.max(0, car.contactCooldown - delta);
2312
- });
2313
-
2314
- this.resolveCollisions();
2315
- this.recalculateRaceState();
2316
- this.reviewTrackLimits();
2317
- this.reviewPitLaneSpeeding();
314
+ runRaceStep(this, dt);
2318
315
  }
2319
316
 
2320
317
  recordPenalty(penalty) {
@@ -2338,13 +335,7 @@ export class F1RaceSimulation {
2338
335
  const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2339
336
  const result = servePenaltyRecord(penalty, this.time);
2340
337
  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
- });
338
+ this.events.unshift(createPenaltyServedEvent(result, this.time));
2348
339
  }
2349
340
  return result;
2350
341
  }
@@ -2353,12 +344,7 @@ export class F1RaceSimulation {
2353
344
  const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2354
345
  const result = cancelPenaltyRecord(penalty, this.time);
2355
346
  if (result) {
2356
- this.events.unshift({
2357
- type: 'penalty-cancelled',
2358
- at: this.time,
2359
- penaltyId: result.id,
2360
- driverId: result.driverId,
2361
- });
347
+ this.events.unshift(createPenaltyCancelledEvent(result, this.time));
2362
348
  }
2363
349
  return result;
2364
350
  }
@@ -2380,175 +366,45 @@ export class F1RaceSimulation {
2380
366
  }
2381
367
 
2382
368
  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
- });
369
+ applyGridDropForSimulation(this, driverId, positions);
2414
370
  }
2415
371
 
2416
372
  reviewCollision(first, second, collision) {
2417
- const rule = getPenaltyRule(this.rules, 'collision');
2418
- calculateCollisionPenalties({ first, second, collision, rule })
2419
- .forEach((penalty) => this.recordPenalty(penalty));
373
+ reviewCollisionForSimulation(this, first, second, collision);
2420
374
  }
2421
375
 
2422
376
  reviewTireRequirement(car) {
2423
377
  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
378
  this.stewardState.tireRequirement[car.id] = true;
2431
- if (penalty) this.recordPenalty(penalty);
379
+ reviewTireRequirementForSimulation(this, car);
2432
380
  }
2433
381
 
2434
382
  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
- });
383
+ reviewTrackLimitsForSimulation(this);
2453
384
  }
2454
385
 
2455
386
  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
- });
387
+ reviewPitLaneSpeedingForSimulation(this);
388
+ }
389
+
390
+ vehicleSnapshotDependencies() {
391
+ return createVehicleSnapshotDependencies(this);
2473
392
  }
2474
393
 
2475
394
  snapshot() {
2476
- const ordered = this.orderedCars();
2477
- const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
2478
- const penaltyStats = this.getPenaltyStatsByDriver();
2479
- return {
2480
- time: this.time,
2481
- world: WORLD,
2482
- track: this.track,
2483
- totalLaps: this.totalLaps,
2484
- raceControl: {
2485
- mode: this.raceControl.mode,
2486
- redFlag: Boolean(this.raceControl.redFlag),
2487
- pitLaneOpen: pitLaneStatus.open,
2488
- pitLaneStatus,
2489
- finished: this.raceControl.finished,
2490
- finishedAt: this.raceControl.finishedAt,
2491
- winner: this.getRaceWinnerSnapshot(),
2492
- classification: this.raceControl.classification.map((entry) => ({ ...entry })),
2493
- start: {
2494
- ...this.raceControl.start,
2495
- visible: this.raceControl.mode === 'pre-start' ||
2496
- (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
2497
- },
2498
- },
2499
- pitLaneStatus,
2500
- safetyCar: { ...this.safetyCar },
2501
- rules: this.rules,
2502
- events: [...this.events],
2503
- penalties: this.penalties.map(serializePenalty),
2504
- cars: ordered.map((car, index) => serializeCar(car, index + 1, getPenaltyStats(penaltyStats, car.id).seconds)),
2505
- };
395
+ return snapshotRace(this);
2506
396
  }
2507
397
 
2508
398
  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),
2530
- };
399
+ return snapshotRaceRender(this);
2531
400
  }
2532
401
 
2533
402
  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
- };
403
+ return snapshotRaceObservation(this);
404
+ }
405
+
406
+ snapshotTraining() {
407
+ return snapshotRaceTraining(this);
2552
408
  }
2553
409
 
2554
410
  consumeStepEvents() {
@@ -2556,552 +412,109 @@ export class F1RaceSimulation {
2556
412
  }
2557
413
 
2558
414
  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
-
2587
- if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
2588
- return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
2589
- }
2590
-
2591
- return sortLive(this.cars);
415
+ return orderedCarsForSimulation(this);
2592
416
  }
2593
417
 
2594
418
  driverRaceContext(orderedCars = this.orderedCars()) {
2595
- return {
2596
- track: this.track,
2597
- cars: this.cars,
2598
- orderedCars,
2599
- safetyCar: this.safetyCar,
2600
- rules: this.rules,
2601
- };
419
+ return driverRaceContextForSimulation(this, orderedCars);
2602
420
  }
2603
421
 
2604
422
  computeAggression(car, orderIndex = Math.max(0, (car.rank ?? 1) - 1)) {
2605
- const personality = car.personality ?? { baseAggression: 0.5, riskTolerance: 0.5, patience: 0.5 };
2606
- if (this.safetyCar.deployed || car.canAttack === false) {
2607
- return clamp(personality.baseAggression * 0.62, 0.08, 0.62);
2608
- }
2609
-
2610
- const fieldDepth = Math.max(1, this.cars.length - 1);
2611
- const positionPressure = clamp(orderIndex / fieldDepth, 0, 1);
2612
- const gapPressure = Number.isFinite(car.gapAhead) ? clamp((230 - car.gapAhead) / 230, 0, 1) : 0;
2613
- const tireConfidence = clamp(((car.tireEnergy ?? 100) - 42) / 58, 0, 1);
2614
- const patienceDamping = (1 - gapPressure) * personality.patience * 0.08;
2615
-
2616
- return clamp(
2617
- personality.baseAggression
2618
- + positionPressure * 0.26
2619
- + gapPressure * (0.1 + personality.riskTolerance * 0.08)
2620
- - (1 - tireConfidence) * 0.1
2621
- - patienceDamping,
2622
- 0.08,
2623
- 1,
2624
- );
423
+ return computeAggressionForSimulation(this, car, orderIndex);
2625
424
  }
2626
425
 
2627
426
  updateStartSequence() {
2628
- const start = this.raceControl.start;
2629
- if (this.raceControl.mode !== 'pre-start' || !start || start.released) return;
2630
-
2631
- if (this.time >= start.lightsOutAt) {
2632
- this.releaseRaceStart();
2633
- this.events.unshift({ type: 'start-lights-out', at: this.time });
2634
- return;
2635
- }
2636
-
2637
- start.lightsLit = clamp(
2638
- Math.floor((this.time + Number.EPSILON) / this.rules.startLightInterval),
2639
- 0,
2640
- start.lightCount,
2641
- );
427
+ return updateStartSequenceForSimulation(this);
2642
428
  }
2643
429
 
2644
430
  releaseRaceStart() {
2645
- const start = this.raceControl.start;
2646
- this.raceControl.mode = 'green';
2647
- start.lightsLit = 0;
2648
- start.released = true;
2649
- start.releasedAt = this.time;
2650
- this.cars.forEach((car) => {
2651
- const wasGridLocked = car.gridLocked;
2652
- car.gridLocked = false;
2653
- const state = nearestTrackState(this.track, car, car.gridDistance);
2654
- car.progress = state.distance;
2655
- applyWheelSurfaceState(car, this.track);
2656
- if (wasGridLocked) car.raceDistance = car.gridDistance;
2657
- if (wasGridLocked) car.desiredOffset = 0;
2658
- car.previousX = car.x;
2659
- car.previousY = car.y;
2660
- car.previousHeading = car.heading;
2661
- });
431
+ return releaseRaceStartForSimulation(this);
2662
432
  }
2663
433
 
2664
434
  holdGridCars() {
2665
- this.cars.forEach((car) => {
2666
- if (!car.gridLocked) return;
2667
- const gridPoint = pointAt(this.track, car.gridDistance);
2668
- const position = offsetTrackPoint(gridPoint, car.gridOffset);
2669
- car.previousX = position.x;
2670
- car.previousY = position.y;
2671
- car.previousHeading = gridPoint.heading;
2672
- car.x = position.x;
2673
- car.y = position.y;
2674
- car.heading = gridPoint.heading;
2675
- car.speed = 0;
2676
- car.throttle = 0;
2677
- car.brake = 1;
2678
- car.steeringAngle = 0;
2679
- car.yawRate = 0;
2680
- car.turnRadius = Infinity;
2681
- car.progress = gridPoint.distance;
2682
- car.raceDistance = car.gridDistance;
2683
- applyWheelSurfaceState(car, this.track);
2684
- });
435
+ return holdGridCarsForSimulation(this);
2685
436
  }
2686
437
 
2687
438
  updateSafetyCar(dt) {
2688
- if (!this.safetyCar.deployed) return;
2689
- const leader = this.orderedCars()[0];
2690
- const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
2691
- const progress = this.safetyCar.progress < targetProgress
2692
- ? Math.min(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress)
2693
- : targetProgress;
2694
- this.moveSafetyCarTo(progress);
439
+ updateSafetyCarState(this, dt);
2695
440
  }
2696
441
 
2697
442
  moveSafetyCarTo(progress) {
2698
- const point = pointAt(this.track, progress);
2699
- this.safetyCar.previousX = this.safetyCar.x;
2700
- this.safetyCar.previousY = this.safetyCar.y;
2701
- this.safetyCar.previousHeading = this.safetyCar.heading;
2702
- this.safetyCar.progress = progress;
2703
- this.safetyCar.x = point.x;
2704
- this.safetyCar.y = point.y;
2705
- this.safetyCar.heading = point.heading;
443
+ moveSafetyCarToState(this, progress);
2706
444
  }
2707
445
 
2708
446
  applyRunoffResponse(car) {
2709
- const state = nearestTrackState(this.track, car, car.progress);
2710
- if (state.inPitLane) {
2711
- applyWheelSurfaceState(car, this.track, { centerState: state });
2712
- return;
2713
- }
2714
- const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
2715
- const overshoot = Math.abs(state.signedOffset) - signedLimit;
2716
- if (overshoot <= 0) {
2717
- applyWheelSurfaceState(car, this.track, { centerState: state });
2718
- return;
2719
- }
2720
-
2721
- const side = Math.sign(state.signedOffset) || 1;
2722
- car.x -= state.normalX * side * overshoot;
2723
- car.y -= state.normalY * side * overshoot;
2724
- car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
2725
- car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
2726
- applyWheelSurfaceState(car, this.track);
447
+ applyRunoffResponseForSimulation(this, car);
2727
448
  }
2728
449
 
2729
450
  recalculateRaceState({ updateDrs = true } = {}) {
2730
- this.cars.forEach((car) => {
2731
- const previousRaceDistance = car.raceDistance;
2732
- if (car.gridLocked) {
2733
- const gridPoint = pointAt(this.track, car.gridDistance);
2734
- applyWheelSurfaceState(car, this.track);
2735
- car.progress = gridPoint.distance;
2736
- car.raceDistance = car.gridDistance;
2737
- car.lap = 1;
2738
- resetLapTelemetry(car, this.time, this.track, this.totalLaps);
2739
- return;
2740
- }
2741
-
2742
- applyWheelSurfaceState(car, this.track);
2743
- const previousProgress = car.progress ?? car.trackState.distance;
2744
- const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
2745
- car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
2746
- car.progress = car.trackState.distance;
2747
- car.lap = this.computeLap(car.raceDistance);
2748
- updateLapTelemetry(car, previousRaceDistance, this.time, this.track, this.totalLaps);
2749
- });
2750
-
2751
- updateSectorPerformance(this.cars);
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
- });
2757
-
2758
- const ordered = this.orderedCars();
2759
- const leader = ordered[0];
2760
- ordered.forEach((car, index) => {
2761
- const ahead = ordered[index - 1];
2762
- const drsReference = this.getDrsReferenceCar(car);
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);
2767
- car.rank = index + 1;
2768
- car.gapAhead = gap;
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;
2775
- car.intervalAheadSeconds = car.gapAheadSeconds;
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;
2780
- car.aggression = this.computeAggression(car, 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));
2787
- });
2788
- this.evaluateRaceFinish();
451
+ return recalculateRaceStateForSimulation(this, { updateDrs });
2789
452
  }
2790
453
 
2791
454
  evaluateRaceFinish() {
2792
- if (this.raceControl.finished) return;
2793
- if (this.raceControl.mode === 'pre-start') return;
2794
-
2795
- const ordered = this.orderedCars();
2796
- const newlyFinished = ordered.filter((car) => !car.finished && car.raceDistance >= this.finishDistance);
2797
- if (newlyFinished.length === 0) return;
2798
-
2799
- newlyFinished.forEach((car) => {
2800
- car.finished = true;
2801
- car.finishTime = this.time;
2802
- car.finishRank = this.raceControl.finishOrder.length + 1;
2803
- car.classifiedRank = car.finishRank;
2804
- this.raceControl.finishOrder.push(car.id);
2805
- if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
2806
- car.drsActive = false;
2807
- car.drsEligible = false;
2808
- car.drsZoneId = null;
2809
- car.drsZoneEnabled = false;
2810
- car.canAttack = false;
2811
- this.events.unshift({
2812
- type: 'car-finish',
2813
- at: this.time,
2814
- carId: car.id,
2815
- rank: car.finishRank,
2816
- winnerId: this.raceControl.winnerId,
2817
- });
2818
- this.reviewTireRequirement(car);
2819
- });
2820
-
2821
- if (!this.cars.every((car) => car.finished)) return;
2822
-
2823
- this.applyOutstandingServicePenalties();
2824
- const classification = this.buildClassificationFromFinishOrder();
2825
- this.raceControl.winnerId = classification[0]?.id ?? this.raceControl.winnerId;
2826
- this.raceControl.mode = 'safety-car';
2827
- this.raceControl.finished = true;
2828
- this.raceControl.finishedAt = this.time;
2829
- this.raceControl.classification = classification;
2830
- this.raceControl.frozenOrder = classification.map((entry) => entry.id);
2831
- this.safetyCar.deployed = true;
2832
- const leader = this.cars.find((car) => car.id === this.raceControl.winnerId) ?? ordered[0];
2833
- const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
2834
- if (this.safetyCar.progress < safetyCarProgress) {
2835
- this.moveSafetyCarTo(safetyCarProgress);
2836
- }
2837
- this.cars.forEach((car) => {
2838
- const classified = classification.find((entry) => entry.id === car.id);
2839
- car.classifiedRank = classified?.rank ?? car.rank;
2840
- car.desiredOffset = 0;
2841
- car.drsActive = false;
2842
- car.drsEligible = false;
2843
- car.drsZoneId = null;
2844
- car.drsZoneEnabled = false;
2845
- car.canAttack = false;
2846
- });
2847
- this.events.unshift({
2848
- type: 'race-finish',
2849
- at: this.time,
2850
- winnerId: this.raceControl.winnerId,
2851
- classification: classification.map((entry) => ({ id: entry.id, rank: entry.rank })),
2852
- });
455
+ evaluateRaceFinishForSimulation(this);
2853
456
  }
2854
457
 
2855
458
  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
- });
459
+ applyOutstandingServicePenaltiesState(this, applyUnservedServicePenalty);
2870
460
  }
2871
461
 
2872
462
  buildClassificationFromFinishOrder() {
2873
- const byId = new Map(this.cars.map((car) => [car.id, car]));
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);
463
+ return buildClassificationFromFinishOrderState(this);
2886
464
  }
2887
465
 
2888
466
  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
- ];
467
+ return applyClassificationConsequencesState(this, ordered, penaltyStats);
2904
468
  }
2905
469
 
2906
470
  buildClassification(ordered = this.orderedCars(), penaltyStats = this.getPenaltyStatsByDriver()) {
2907
- const leaderDistance = ordered[0]?.raceDistance ?? 0;
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
- });
471
+ return buildClassificationState(this, ordered, penaltyStats);
2937
472
  }
2938
473
 
2939
474
  getRaceWinnerSnapshot() {
2940
- if (!this.raceControl.winnerId) return null;
2941
- const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
2942
- if (!car) return null;
2943
- const rank = car.classifiedRank ?? car.rank ?? 1;
2944
- return serializeCar(car, rank, getPenaltyStats(this.getPenaltyStatsByDriver(), car.id).seconds);
475
+ return getRaceWinnerSnapshotForSimulation(this);
2945
476
  }
2946
477
 
2947
478
  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;
479
+ return getDrsReferenceCarForSimulation(this, car);
2956
480
  }
2957
481
 
2958
482
  updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
2959
- if (this.safetyCar.deployed || car.finished) {
2960
- car.drsEligible = false;
2961
- car.drsActive = false;
2962
- car.drsZoneId = null;
2963
- car.drsZoneEnabled = false;
2964
- return;
2965
- }
2966
-
2967
- const previousProgress = car.previousProgress ?? car.progress;
2968
- const currentZone = car.drsZoneId
2969
- ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
2970
- : null;
2971
-
2972
- if (currentZone && !isProgressInZone(this.track, car.progress, currentZone)) {
2973
- car.drsZoneId = null;
2974
- car.drsZoneEnabled = false;
2975
- }
2976
-
2977
- if (!car.drsZoneId) {
2978
- const crossedZone = this.track.drsZones.find((zone) => (
2979
- crossesDistance(previousProgress, car.progress, zone.start, this.track.length)
2980
- ));
2981
- if (crossedZone) {
2982
- car.drsZoneId = crossedZone.id;
2983
- const crossing = recordDrsDetection(car, crossedZone.id, this.time);
2984
- const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
2985
- car.drsZoneEnabled = Boolean(
2986
- hasReference &&
2987
- aheadCrossing &&
2988
- aheadCrossing.passage === crossing.passage &&
2989
- crossing.time >= aheadCrossing.time &&
2990
- crossing.time - aheadCrossing.time <= this.rules.drsDetectionSeconds + 1e-6
2991
- );
2992
- }
2993
- }
2994
-
2995
- const activeZone = car.drsZoneId
2996
- ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
2997
- : null;
2998
- const inLatchedZone = activeZone ? isProgressInZone(this.track, car.progress, activeZone) : false;
2999
- car.drsEligible = Boolean(car.drsZoneEnabled && inLatchedZone);
3000
- car.drsActive = car.drsEligible;
483
+ updateDrsLatchState(car, ahead, {
484
+ hasReference,
485
+ safetyCarDeployed: this.safetyCar.deployed,
486
+ time: this.time,
487
+ track: this.track,
488
+ rules: this.rules,
489
+ });
3001
490
  }
3002
491
 
3003
492
  resolveCollisions() {
3004
- const reportedContacts = new Set();
3005
-
3006
- for (let pass = 0; pass < 3; pass += 1) {
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
- }
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 {
3041
- this.applyContactVelocityResponse(first, second, collision.axis);
3042
- }
3043
-
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);
3074
- }
3075
- }
3076
- }
493
+ resolveCollisionsForSimulation(this);
3077
494
  }
3078
495
 
3079
496
  applyContactVelocityResponse(first, second, axis) {
3080
- const firstForward = forwardVector(first);
3081
- const secondForward = forwardVector(second);
3082
- const firstNormal = dot(firstForward, axis);
3083
- const secondNormal = dot(secondForward, axis);
3084
- const relativeNormalVelocity = second.speed * secondNormal - first.speed * firstNormal;
3085
-
3086
- if (relativeNormalVelocity < 0) {
3087
- const impulse = clamp(-relativeNormalVelocity * (0.34 + this.rules.collisionRestitution), 0, 16);
3088
- if (firstNormal > 0) first.speed = clamp(first.speed - impulse * firstNormal, 0, VEHICLE_LIMITS.maxSpeed);
3089
- if (secondNormal < 0) second.speed = clamp(second.speed + impulse * secondNormal, 0, VEHICLE_LIMITS.maxSpeed);
3090
- }
3091
-
3092
- first.speed = clamp(first.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
3093
- second.speed = clamp(second.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
497
+ applyContactVelocityResponseForSimulation(this, first, second, axis);
3094
498
  }
3095
499
 
3096
500
  computeLap(raceDistance) {
3097
- return clamp(Math.floor(Math.max(0, raceDistance) / this.track.length) + 1, 1, this.totalLaps);
501
+ return computeLapForDistance(raceDistance, this.track.length, this.totalLaps);
3098
502
  }
3099
503
 
3100
504
  get finishDistance() {
3101
- return this.track.length * this.totalLaps;
505
+ return finishDistanceForRace(this.track.length, this.totalLaps);
3102
506
  }
3103
507
  }
3104
508
 
3105
509
  export function createRaceSimulation(options = {}) {
3106
- return new F1RaceSimulation(options);
510
+ const resolvedOptions = withWarmupSurfaceOptions(options, options?.warmup?.surface ?? 'simulation');
511
+ runWarmupWithGuard({
512
+ options: resolvedOptions,
513
+ surface: resolvedOptions.warmup.surface,
514
+ execute: ({ warmup }) => {
515
+ const warmupSimulation = new F1RaceSimulation(disableWarmupOptions(resolvedOptions, warmup.surface));
516
+ for (let index = 0; index < warmup.steps; index += 1) warmupSimulation.step(FIXED_STEP);
517
+ },
518
+ });
519
+ return new F1RaceSimulation(resolvedOptions);
3107
520
  }