@inventure71/paddockjs 1.0.0 → 2.0.1

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 (203) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +219 -12
  4. package/docs/architecture.md +126 -45
  5. package/docs/concepts.md +9 -5
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +317 -22
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +10 -0
  10. package/docs/learnings.md +22 -0
  11. package/docs/rules.md +71 -14
  12. package/docs/sense_contract.md +59 -0
  13. package/docs/system_specs.md +75 -13
  14. package/docs/training.md +280 -32
  15. package/examples/python/README.md +62 -0
  16. package/examples/python/base_policy_server.py +191 -0
  17. package/examples/python/requirements.txt +2 -0
  18. package/package.json +12 -4
  19. package/src/api/PaddockSimulatorController.js +6 -33
  20. package/src/app/BrowserExpertAdapter.js +76 -9
  21. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  22. package/src/app/F1SimulatorApp.js +291 -1630
  23. package/src/app/banners/bannerFormatters.js +55 -0
  24. package/src/app/banners/raceDataBannerController.js +141 -0
  25. package/src/app/banners/stewardMessageController.js +99 -0
  26. package/src/app/camera/cameraConstants.js +21 -0
  27. package/src/app/camera/cameraController.js +265 -0
  28. package/src/app/camera/cameraGeometry.js +176 -0
  29. package/src/app/domBindings.js +4 -0
  30. package/src/app/readouts/carOverviewRenderer.js +109 -0
  31. package/src/app/readouts/controlStateRenderer.js +37 -0
  32. package/src/app/readouts/raceStatusRenderer.js +133 -0
  33. package/src/app/readouts/readoutFormatters.js +58 -0
  34. package/src/app/readouts/telemetryRenderer.js +113 -0
  35. package/src/app/readouts/timingTowerRenderer.js +177 -0
  36. package/src/app/rendering/appAssets.js +38 -0
  37. package/src/app/rendering/carRenderer.js +286 -0
  38. package/src/app/rendering/displayUtils.js +22 -0
  39. package/src/app/rendering/drsTrailRenderer.js +90 -0
  40. package/src/app/rendering/expertSensorRenderer.js +126 -0
  41. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  42. package/src/app/rendering/replayGhostRenderer.js +124 -0
  43. package/src/app/rendering/trackRenderer.js +25 -0
  44. package/src/app/runtime/frameLoop.js +66 -0
  45. package/src/app/runtime/runtimeTiming.js +34 -0
  46. package/src/app/runtime/runtimeVisibility.js +45 -0
  47. package/src/config/defaultOptions.js +20 -0
  48. package/src/config/restartOptions.js +34 -0
  49. package/src/environment/actions.js +13 -5
  50. package/src/environment/controllerLoop.js +261 -0
  51. package/src/environment/episode.js +98 -2
  52. package/src/environment/evaluation.js +54 -32
  53. package/src/environment/index.d.ts +376 -10
  54. package/src/environment/index.js +1 -0
  55. package/src/environment/metrics.js +159 -0
  56. package/src/environment/observationOptions.js +22 -1
  57. package/src/environment/observationVector.js +200 -0
  58. package/src/environment/observations.js +199 -141
  59. package/src/environment/options.js +37 -5
  60. package/src/environment/recorder.js +13 -6
  61. package/src/environment/rewards.js +13 -2
  62. package/src/environment/runtime.js +272 -23
  63. package/src/environment/scenarios.js +57 -9
  64. package/src/environment/sensors/bodySenses.js +23 -0
  65. package/src/environment/sensors/boundarySenses.js +17 -0
  66. package/src/environment/sensors/carRays.js +75 -0
  67. package/src/environment/sensors/contactSenses.js +32 -0
  68. package/src/environment/sensors/index.js +157 -0
  69. package/src/environment/sensors/indexedRayBands.js +103 -0
  70. package/src/environment/sensors/nearbyCars.js +52 -0
  71. package/src/environment/sensors/opponentRadar.js +75 -0
  72. package/src/environment/sensors/rayConfig.js +110 -0
  73. package/src/environment/sensors/rayDefaults.js +6 -0
  74. package/src/environment/sensors/rayGeometry.js +60 -0
  75. package/src/environment/sensors/rayGuards.js +19 -0
  76. package/src/environment/sensors/sensorTargets.js +66 -0
  77. package/src/environment/sensors/surfaceRays.js +360 -0
  78. package/src/environment/sensors/trackRays.js +424 -0
  79. package/src/environment/sensors.js +1 -367
  80. package/src/environment/specs.js +159 -26
  81. package/src/environment/workerProtocol.js +13 -1
  82. package/src/index.d.ts +171 -6
  83. package/src/index.js +6 -32
  84. package/src/rendering/proceduralTrackAsset.js +20 -823
  85. package/src/rendering/renderSnapshot.js +38 -12
  86. package/src/rendering/track/finishLineRenderer.js +34 -0
  87. package/src/rendering/track/gridRenderer.js +47 -0
  88. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  89. package/src/rendering/track/pitLaneRenderer.js +248 -0
  90. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  91. package/src/rendering/track/trackRenderConstants.js +38 -0
  92. package/src/rendering/track/trackRenderGeometry.js +94 -0
  93. package/src/simulation/collisionGeometry.js +1 -1
  94. package/src/simulation/driver/driverControlConstants.js +28 -0
  95. package/src/simulation/driver/driverController.js +47 -0
  96. package/src/simulation/driver/driverInput.js +28 -0
  97. package/src/simulation/driver/driverMath.js +6 -0
  98. package/src/simulation/driver/driverPersonality.js +25 -0
  99. package/src/simulation/driver/edgeRecovery.js +48 -0
  100. package/src/simulation/driver/racingControls.js +399 -0
  101. package/src/simulation/driver/racingLinePlan.js +166 -0
  102. package/src/simulation/driver/recoveryDynamics.js +38 -0
  103. package/src/simulation/driver/rejoinControls.js +213 -0
  104. package/src/simulation/driver/safetyCarControls.js +35 -0
  105. package/src/simulation/driver/trafficScan.js +51 -0
  106. package/src/simulation/driverController.js +4 -619
  107. package/src/simulation/participants/participantInteractions.js +120 -0
  108. package/src/simulation/pit/pitFlow.js +239 -0
  109. package/src/simulation/pit/pitIntent.js +92 -0
  110. package/src/simulation/pit/pitKinematics.js +24 -0
  111. package/src/simulation/pit/pitOccupancy.js +47 -0
  112. package/src/simulation/pit/pitPenaltyService.js +71 -0
  113. package/src/simulation/pit/pitQueue.js +60 -0
  114. package/src/simulation/pit/pitRouteMovement.js +167 -0
  115. package/src/simulation/pit/pitRouting.js +319 -0
  116. package/src/simulation/pit/pitService.js +43 -0
  117. package/src/simulation/pit/pitServiceConstants.js +21 -0
  118. package/src/simulation/pit/pitServiceProfile.js +45 -0
  119. package/src/simulation/pit/pitSnapshots.js +61 -0
  120. package/src/simulation/pit/pitState.js +113 -0
  121. package/src/simulation/pit/pitTireService.js +94 -0
  122. package/src/simulation/race/classification.js +104 -0
  123. package/src/simulation/race/gridPenalties.js +37 -0
  124. package/src/simulation/race/raceControlState.js +38 -0
  125. package/src/simulation/race/raceDistance.js +32 -0
  126. package/src/simulation/race/raceFinish.js +65 -0
  127. package/src/simulation/race/raceLifecycle.js +67 -0
  128. package/src/simulation/race/raceOrder.js +113 -0
  129. package/src/simulation/race/raceProgress.js +129 -0
  130. package/src/simulation/race/raceSetup.js +93 -0
  131. package/src/simulation/race/raceStep.js +80 -0
  132. package/src/simulation/race/redFlag.js +15 -0
  133. package/src/simulation/race/retirements.js +44 -0
  134. package/src/simulation/race/safetyCar.js +92 -0
  135. package/src/simulation/race/stalledDnf.js +77 -0
  136. package/src/simulation/raceSimulation.js +231 -2817
  137. package/src/simulation/replay/replayGhosts.js +128 -0
  138. package/src/simulation/rules/moduleConfig.js +72 -0
  139. package/src/simulation/rules/penaltyConfig.js +99 -0
  140. package/src/simulation/rules/penaltyEvents.js +18 -0
  141. package/src/simulation/rules/penaltyStats.js +26 -0
  142. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  143. package/src/simulation/rules/ruleDefaults.js +85 -0
  144. package/src/simulation/rules/rulesReview.js +144 -0
  145. package/src/simulation/rules/rulesetPresets.js +47 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  147. package/src/simulation/rulesConfig.js +5 -323
  148. package/src/simulation/snapshots/raceSnapshots.js +193 -0
  149. package/src/simulation/timing/drsTiming.js +87 -0
  150. package/src/simulation/timing/gapEstimation.js +38 -0
  151. package/src/simulation/timing/lapTelemetry.js +182 -0
  152. package/src/simulation/timing/raceTiming.js +15 -0
  153. package/src/simulation/timing/sectorPerformance.js +60 -0
  154. package/src/simulation/timing/timingConstants.js +5 -0
  155. package/src/simulation/timing/timingHistory.js +46 -0
  156. package/src/simulation/timing/timingLines.js +79 -0
  157. package/src/simulation/track/centerlineSpline.js +59 -0
  158. package/src/simulation/track/drsZones.js +67 -0
  159. package/src/simulation/track/pitLaneAccess.js +103 -0
  160. package/src/simulation/track/pitLaneLayout.js +395 -0
  161. package/src/simulation/track/pitLaneState.js +160 -0
  162. package/src/simulation/track/proceduralCenterline.js +237 -0
  163. package/src/simulation/track/sampleGeometry.js +49 -0
  164. package/src/simulation/track/spatialQueries.js +154 -0
  165. package/src/simulation/track/startStraight.js +141 -0
  166. package/src/simulation/track/trackConstants.js +109 -0
  167. package/src/simulation/track/trackGenerationOptions.js +283 -0
  168. package/src/simulation/track/trackMath.js +241 -0
  169. package/src/simulation/track/trackModel.js +240 -0
  170. package/src/simulation/track/trackQueryGrid.js +94 -0
  171. package/src/simulation/track/trackQueryIndex.js +659 -0
  172. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  173. package/src/simulation/track/trackQueryScratch.js +46 -0
  174. package/src/simulation/track/trackQueryStats.js +28 -0
  175. package/src/simulation/track/trackSectors.js +19 -0
  176. package/src/simulation/track/trackStatePolicy.js +24 -0
  177. package/src/simulation/track/trackValidation.js +98 -0
  178. package/src/simulation/trackModel.js +1 -1706
  179. package/src/simulation/vehicle/contactResolution.js +181 -0
  180. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  181. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  182. package/src/simulation/vehicle/runoffResponse.js +87 -0
  183. package/src/simulation/vehicle/surfacePriority.js +28 -0
  184. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  185. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  186. package/src/simulation/vehicle/vehiclePhysics.js +364 -0
  187. package/src/simulation/vehicle/vehicleSnapshots.js +327 -0
  188. package/src/simulation/vehicle/vehicleState.js +220 -0
  189. package/src/simulation/vehicle/wheelSurface.js +152 -0
  190. package/src/simulation/vehicleGeometry.js +1 -247
  191. package/src/simulation/vehiclePhysics.js +1 -136
  192. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  193. package/src/simulation/wheelSurface.js +1 -338
  194. package/src/styles.css +42 -0
  195. package/src/ui/cameraControlsTemplate.js +20 -0
  196. package/src/ui/carOverviewTemplate.js +33 -0
  197. package/src/ui/componentTemplates.js +14 -360
  198. package/src/ui/raceCanvasTemplate.js +69 -0
  199. package/src/ui/raceControlsTemplate.js +36 -0
  200. package/src/ui/shellTemplate.js +5 -4
  201. package/src/ui/telemetryDrawerTemplate.js +55 -0
  202. package/src/ui/telemetryTemplates.js +136 -0
  203. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,1544 +1,176 @@
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
+ physicsMode: this.physicsMode,
156
+ progressDelta,
157
+ raceControl: this.raceControl,
158
+ releaseRaceStart: () => this.releaseRaceStart(),
159
+ resetLapTelemetry,
160
+ resetTimingHistory,
161
+ resetTimingLineCrossings,
162
+ time: this.time,
163
+ totalLaps: this.totalLaps,
164
+ track: this.track,
165
+ };
166
+ let applied = false;
167
+ Object.entries(partialsById).forEach(([id, partial]) => {
168
+ const car = this.cars.find((item) => item.id === id);
169
+ if (!car) return;
170
+ applyExternalCarState(car, partial, context);
171
+ applied = true;
172
+ });
173
+ if (!applied) return;
1542
174
  this.recalculateRaceState({ updateDrs: false });
1543
175
  this.evaluateRaceFinish();
1544
176
  }
@@ -1563,7 +195,7 @@ export class F1RaceSimulation {
1563
195
 
1564
196
  getPitIntent(id) {
1565
197
  const car = this.cars.find((item) => item.id === id);
1566
- return normalizePitIntent(car?.pitStop?.intent) ?? PIT_INTENT_NONE;
198
+ return normalizePitIntentForRace(car);
1567
199
  }
1568
200
 
1569
201
  getPitTargetCompound(id) {
@@ -1572,749 +204,115 @@ export class F1RaceSimulation {
1572
204
  }
1573
205
 
1574
206
  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;
207
+ return setPitIntentForSimulation(this, id, intent, targetCompound);
1609
208
  }
1610
209
 
1611
210
  isCarInActivePitStop(car) {
1612
- return Boolean(car.pitStop && car.pitStop.status !== 'pending' && car.pitStop.status !== 'completed');
211
+ return isCarInActivePitStopForSimulation(this, car);
1613
212
  }
1614
213
 
1615
214
  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;
215
+ return schedulePitStopAtNextEntryForSimulation(this, car, stop);
1632
216
  }
1633
217
 
1634
218
  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);
219
+ return rearmCompletedPitStopForSimulation(this, car, stop);
1650
220
  }
1651
221
 
1652
222
  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
- });
223
+ return canStartPitStopForSimulation(this, car);
1671
224
  }
1672
225
 
1673
226
  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));
227
+ return shouldStartPitStopForSimulation(this, car);
1686
228
  }
1687
229
 
1688
230
  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;
231
+ return updateAutomaticPitIntentForSimulation(this, car);
232
+ }
1697
233
 
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;
234
+ isPitLaneOpenForStops() {
235
+ return isPitLaneOpenForStopsForSimulation(this);
236
+ }
1702
237
 
1703
- stop.intent = nextIntent;
1704
- if (stop.status === 'completed') this.rearmCompletedPitStop(car, stop);
1705
- else this.schedulePitStopAtNextEntry(car, stop);
238
+ pitEntryLateThresholdDistance() {
239
+ return pitEntryLateThresholdDistanceForSimulation();
1706
240
  }
1707
241
 
1708
242
  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;
243
+ return getPitStopBoxForSimulation(this, stop);
1712
244
  }
1713
245
 
1714
246
  isPitServiceBusy(car, box) {
1715
- return this.cars.some((candidate) => (
1716
- candidate !== car &&
1717
- candidate.pitStop?.boxId === box?.id &&
1718
- this.isPitServiceAreaOccupied(candidate, box)
1719
- ));
247
+ return isPitServiceBusyForSimulation(this, car, box);
1720
248
  }
1721
249
 
1722
250
  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;
251
+ return isPitServiceAreaOccupiedForSimulation(this, candidate, box);
1730
252
  }
1731
253
 
1732
254
  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
- ));
255
+ return isPitServiceQueueOccupiedForSimulation(this, car, box);
1741
256
  }
1742
257
 
1743
258
  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;
259
+ return getPitBoxRaceDistanceForSimulation(this, stop, box);
1748
260
  }
1749
261
 
1750
262
  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;
263
+ return startPitStopForSimulation(this, car);
1809
264
  }
1810
265
 
1811
266
  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;
267
+ return beginPitQueueForSimulation(this, car, box);
1832
268
  }
1833
269
 
1834
270
  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;
271
+ return releasePitQueueForSimulation(this, car, box, { fromCurrent });
1858
272
  }
1859
273
 
1860
274
  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);
275
+ return beginPitServiceForSimulation(this, car, box);
1880
276
  }
1881
277
 
1882
278
  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;
279
+ return beginPitPenaltyServiceForSimulation(this, car);
1909
280
  }
1910
281
 
1911
282
  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;
283
+ return calculatePitServiceProfileForSimulation(this, car);
1951
284
  }
1952
285
 
1953
286
  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
- });
287
+ return beginTireServiceForSimulation(this, car);
1971
288
  }
1972
289
 
1973
290
  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);
291
+ return completePitPenaltyServiceForSimulation(this, car);
2001
292
  }
2002
293
 
2003
294
  getPitServicePenalties(driverId) {
2004
- return this.penalties.filter((penalty) => (
2005
- penalty.driverId === driverId && isPenaltyPitServiceable(penalty)
2006
- ));
295
+ return getPitServicePenaltiesForSimulation(this, driverId);
2007
296
  }
2008
297
 
2009
298
  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;
299
+ return completePitServiceForSimulation(this, car);
2042
300
  }
2043
301
 
2044
302
  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
- });
303
+ return finishPitExitForSimulation(this, car);
2068
304
  }
2069
305
 
2070
306
  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;
307
+ return applyPitRoutePositionForSimulation(this, car, delta);
2172
308
  }
2173
309
 
2174
310
  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;
311
+ return advancePitStopCarForSimulation(this, car, delta);
2259
312
  }
2260
313
 
2261
314
  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();
315
+ runRaceStep(this, dt);
2318
316
  }
2319
317
 
2320
318
  recordPenalty(penalty) {
@@ -2338,13 +336,7 @@ export class F1RaceSimulation {
2338
336
  const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2339
337
  const result = servePenaltyRecord(penalty, this.time);
2340
338
  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
- });
339
+ this.events.unshift(createPenaltyServedEvent(result, this.time));
2348
340
  }
2349
341
  return result;
2350
342
  }
@@ -2353,12 +345,7 @@ export class F1RaceSimulation {
2353
345
  const penalty = this.penalties.find((entry) => entry.id === penaltyId);
2354
346
  const result = cancelPenaltyRecord(penalty, this.time);
2355
347
  if (result) {
2356
- this.events.unshift({
2357
- type: 'penalty-cancelled',
2358
- at: this.time,
2359
- penaltyId: result.id,
2360
- driverId: result.driverId,
2361
- });
348
+ this.events.unshift(createPenaltyCancelledEvent(result, this.time));
2362
349
  }
2363
350
  return result;
2364
351
  }
@@ -2380,175 +367,45 @@ export class F1RaceSimulation {
2380
367
  }
2381
368
 
2382
369
  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
- });
370
+ applyGridDropForSimulation(this, driverId, positions);
2414
371
  }
2415
372
 
2416
373
  reviewCollision(first, second, collision) {
2417
- const rule = getPenaltyRule(this.rules, 'collision');
2418
- calculateCollisionPenalties({ first, second, collision, rule })
2419
- .forEach((penalty) => this.recordPenalty(penalty));
374
+ reviewCollisionForSimulation(this, first, second, collision);
2420
375
  }
2421
376
 
2422
377
  reviewTireRequirement(car) {
2423
378
  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
379
  this.stewardState.tireRequirement[car.id] = true;
2431
- if (penalty) this.recordPenalty(penalty);
380
+ reviewTireRequirementForSimulation(this, car);
2432
381
  }
2433
382
 
2434
383
  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
- });
384
+ reviewTrackLimitsForSimulation(this);
2453
385
  }
2454
386
 
2455
387
  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
- });
388
+ reviewPitLaneSpeedingForSimulation(this);
389
+ }
390
+
391
+ vehicleSnapshotDependencies() {
392
+ return createVehicleSnapshotDependencies(this);
2473
393
  }
2474
394
 
2475
395
  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
- };
396
+ return snapshotRace(this);
2506
397
  }
2507
398
 
2508
399
  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
- };
400
+ return snapshotRaceRender(this);
2531
401
  }
2532
402
 
2533
403
  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
- };
404
+ return snapshotRaceObservation(this);
405
+ }
406
+
407
+ snapshotTraining() {
408
+ return snapshotRaceTraining(this);
2552
409
  }
2553
410
 
2554
411
  consumeStepEvents() {
@@ -2556,552 +413,109 @@ export class F1RaceSimulation {
2556
413
  }
2557
414
 
2558
415
  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);
416
+ return orderedCarsForSimulation(this);
2592
417
  }
2593
418
 
2594
419
  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
- };
420
+ return driverRaceContextForSimulation(this, orderedCars);
2602
421
  }
2603
422
 
2604
423
  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
- );
424
+ return computeAggressionForSimulation(this, car, orderIndex);
2625
425
  }
2626
426
 
2627
427
  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
- );
428
+ return updateStartSequenceForSimulation(this);
2642
429
  }
2643
430
 
2644
431
  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
- });
432
+ return releaseRaceStartForSimulation(this);
2662
433
  }
2663
434
 
2664
435
  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
- });
436
+ return holdGridCarsForSimulation(this);
2685
437
  }
2686
438
 
2687
439
  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);
440
+ updateSafetyCarState(this, dt);
2695
441
  }
2696
442
 
2697
443
  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;
444
+ moveSafetyCarToState(this, progress);
2706
445
  }
2707
446
 
2708
447
  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);
448
+ applyRunoffResponseForSimulation(this, car);
2727
449
  }
2728
450
 
2729
451
  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();
452
+ return recalculateRaceStateForSimulation(this, { updateDrs });
2789
453
  }
2790
454
 
2791
455
  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
- });
456
+ evaluateRaceFinishForSimulation(this);
2853
457
  }
2854
458
 
2855
459
  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
- });
460
+ applyOutstandingServicePenaltiesState(this, applyUnservedServicePenalty);
2870
461
  }
2871
462
 
2872
463
  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);
464
+ return buildClassificationFromFinishOrderState(this);
2886
465
  }
2887
466
 
2888
467
  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
- ];
468
+ return applyClassificationConsequencesState(this, ordered, penaltyStats);
2904
469
  }
2905
470
 
2906
471
  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
- });
472
+ return buildClassificationState(this, ordered, penaltyStats);
2937
473
  }
2938
474
 
2939
475
  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);
476
+ return getRaceWinnerSnapshotForSimulation(this);
2945
477
  }
2946
478
 
2947
479
  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;
480
+ return getDrsReferenceCarForSimulation(this, car);
2956
481
  }
2957
482
 
2958
483
  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;
484
+ updateDrsLatchState(car, ahead, {
485
+ hasReference,
486
+ safetyCarDeployed: this.safetyCar.deployed,
487
+ time: this.time,
488
+ track: this.track,
489
+ rules: this.rules,
490
+ });
3001
491
  }
3002
492
 
3003
493
  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
- }
494
+ resolveCollisionsForSimulation(this);
3077
495
  }
3078
496
 
3079
497
  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);
498
+ applyContactVelocityResponseForSimulation(this, first, second, axis);
3094
499
  }
3095
500
 
3096
501
  computeLap(raceDistance) {
3097
- return clamp(Math.floor(Math.max(0, raceDistance) / this.track.length) + 1, 1, this.totalLaps);
502
+ return computeLapForDistance(raceDistance, this.track.length, this.totalLaps);
3098
503
  }
3099
504
 
3100
505
  get finishDistance() {
3101
- return this.track.length * this.totalLaps;
506
+ return finishDistanceForRace(this.track.length, this.totalLaps);
3102
507
  }
3103
508
  }
3104
509
 
3105
510
  export function createRaceSimulation(options = {}) {
3106
- return new F1RaceSimulation(options);
511
+ const resolvedOptions = withWarmupSurfaceOptions(options, options?.warmup?.surface ?? 'simulation');
512
+ runWarmupWithGuard({
513
+ options: resolvedOptions,
514
+ surface: resolvedOptions.warmup.surface,
515
+ execute: ({ warmup }) => {
516
+ const warmupSimulation = new F1RaceSimulation(disableWarmupOptions(resolvedOptions, warmup.surface));
517
+ for (let index = 0; index < warmup.steps; index += 1) warmupSimulation.step(FIXED_STEP);
518
+ },
519
+ });
520
+ return new F1RaceSimulation(resolvedOptions);
3107
521
  }