@inventure71/paddockjs 0.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/INSTALL_AND_UPDATE.md +5 -2
  3. package/README.md +328 -13
  4. package/docs/architecture.md +150 -41
  5. package/docs/concepts.md +22 -3
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +474 -34
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +1 -0
  10. package/docs/learnings.md +23 -1
  11. package/docs/rules.md +169 -21
  12. package/docs/system_specs.md +56 -20
  13. package/docs/training.md +369 -25
  14. package/examples/python/README.md +62 -0
  15. package/examples/python/base_policy_server.py +191 -0
  16. package/examples/python/requirements.txt +2 -0
  17. package/examples/train-basic-policy.mjs +5 -6
  18. package/examples/trainingData.mjs +133 -0
  19. package/package.json +15 -4
  20. package/src/api/PaddockSimulatorController.js +36 -2
  21. package/src/app/BrowserExpertAdapter.js +367 -9
  22. package/src/app/F1SimulatorApp.js +615 -940
  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 +14 -1
  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 +24 -4
  48. package/src/environment/actions.js +38 -7
  49. package/src/environment/controllerLoop.js +246 -0
  50. package/src/environment/episode.js +86 -2
  51. package/src/environment/evaluation.js +206 -0
  52. package/src/environment/index.d.ts +638 -6
  53. package/src/environment/index.js +14 -0
  54. package/src/environment/metrics.js +159 -0
  55. package/src/environment/observationOptions.js +33 -0
  56. package/src/environment/observations.js +500 -43
  57. package/src/environment/options.js +41 -2
  58. package/src/environment/recorder.js +28 -0
  59. package/src/environment/runtime.js +260 -24
  60. package/src/environment/scenarios.js +249 -0
  61. package/src/environment/sensors/bodySenses.js +23 -0
  62. package/src/environment/sensors/boundarySenses.js +17 -0
  63. package/src/environment/sensors/carRays.js +75 -0
  64. package/src/environment/sensors/contactSenses.js +32 -0
  65. package/src/environment/sensors/index.js +157 -0
  66. package/src/environment/sensors/indexedRayBands.js +103 -0
  67. package/src/environment/sensors/nearbyCars.js +52 -0
  68. package/src/environment/sensors/opponentRadar.js +65 -0
  69. package/src/environment/sensors/rayConfig.js +110 -0
  70. package/src/environment/sensors/rayDefaults.js +6 -0
  71. package/src/environment/sensors/rayGeometry.js +60 -0
  72. package/src/environment/sensors/rayGuards.js +19 -0
  73. package/src/environment/sensors/sensorTargets.js +60 -0
  74. package/src/environment/sensors/surfaceRays.js +296 -0
  75. package/src/environment/sensors/trackRays.js +261 -0
  76. package/src/environment/sensors.js +1 -257
  77. package/src/environment/specs.js +188 -9
  78. package/src/environment/workerProtocol.js +64 -0
  79. package/src/index.d.ts +533 -7
  80. package/src/index.js +26 -0
  81. package/src/rendering/proceduralTrackAsset.js +20 -510
  82. package/src/rendering/renderSnapshot.js +38 -12
  83. package/src/rendering/track/finishLineRenderer.js +34 -0
  84. package/src/rendering/track/gridRenderer.js +47 -0
  85. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  86. package/src/rendering/track/pitLaneRenderer.js +248 -0
  87. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  88. package/src/rendering/track/trackRenderConstants.js +38 -0
  89. package/src/rendering/track/trackRenderGeometry.js +94 -0
  90. package/src/simulation/collisionGeometry.js +214 -0
  91. package/src/simulation/driver/driverControlConstants.js +28 -0
  92. package/src/simulation/driver/driverController.js +47 -0
  93. package/src/simulation/driver/driverInput.js +28 -0
  94. package/src/simulation/driver/driverMath.js +6 -0
  95. package/src/simulation/driver/driverPersonality.js +25 -0
  96. package/src/simulation/driver/edgeRecovery.js +48 -0
  97. package/src/simulation/driver/racingControls.js +399 -0
  98. package/src/simulation/driver/racingLinePlan.js +166 -0
  99. package/src/simulation/driver/recoveryDynamics.js +38 -0
  100. package/src/simulation/driver/rejoinControls.js +213 -0
  101. package/src/simulation/driver/safetyCarControls.js +35 -0
  102. package/src/simulation/driver/trafficScan.js +51 -0
  103. package/src/simulation/driverController.js +4 -253
  104. package/src/simulation/participants/participantInteractions.js +120 -0
  105. package/src/simulation/pit/pitFlow.js +234 -0
  106. package/src/simulation/pit/pitIntent.js +92 -0
  107. package/src/simulation/pit/pitOccupancy.js +47 -0
  108. package/src/simulation/pit/pitPenaltyService.js +71 -0
  109. package/src/simulation/pit/pitQueue.js +55 -0
  110. package/src/simulation/pit/pitRouteMovement.js +160 -0
  111. package/src/simulation/pit/pitRouting.js +319 -0
  112. package/src/simulation/pit/pitService.js +41 -0
  113. package/src/simulation/pit/pitServiceConstants.js +21 -0
  114. package/src/simulation/pit/pitServiceProfile.js +45 -0
  115. package/src/simulation/pit/pitSnapshots.js +61 -0
  116. package/src/simulation/pit/pitState.js +113 -0
  117. package/src/simulation/pit/pitTireService.js +92 -0
  118. package/src/simulation/race/classification.js +104 -0
  119. package/src/simulation/race/gridPenalties.js +37 -0
  120. package/src/simulation/race/raceControlState.js +38 -0
  121. package/src/simulation/race/raceDistance.js +32 -0
  122. package/src/simulation/race/raceFinish.js +65 -0
  123. package/src/simulation/race/raceLifecycle.js +71 -0
  124. package/src/simulation/race/raceOrder.js +113 -0
  125. package/src/simulation/race/raceProgress.js +124 -0
  126. package/src/simulation/race/raceSetup.js +93 -0
  127. package/src/simulation/race/raceStep.js +80 -0
  128. package/src/simulation/race/redFlag.js +15 -0
  129. package/src/simulation/race/retirements.js +44 -0
  130. package/src/simulation/race/safetyCar.js +86 -0
  131. package/src/simulation/race/stalledDnf.js +86 -0
  132. package/src/simulation/raceSimulation.js +386 -1012
  133. package/src/simulation/replay/replayGhosts.js +128 -0
  134. package/src/simulation/rules/collisionSteward.js +69 -0
  135. package/src/simulation/rules/moduleConfig.js +72 -0
  136. package/src/simulation/rules/penaltyConfig.js +99 -0
  137. package/src/simulation/rules/penaltyEvents.js +18 -0
  138. package/src/simulation/rules/penaltyLedger.js +153 -0
  139. package/src/simulation/rules/penaltyStats.js +26 -0
  140. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  141. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  142. package/src/simulation/rules/ruleDefaults.js +85 -0
  143. package/src/simulation/rules/rulesReview.js +141 -0
  144. package/src/simulation/rules/rulesetPresets.js +47 -0
  145. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  147. package/src/simulation/rulesConfig.js +34 -0
  148. package/src/simulation/snapshots/raceSnapshots.js +192 -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/trackQueryIndex.js +945 -0
  171. package/src/simulation/track/trackSectors.js +19 -0
  172. package/src/simulation/track/trackStatePolicy.js +24 -0
  173. package/src/simulation/track/trackValidation.js +98 -0
  174. package/src/simulation/trackModel.js +1 -628
  175. package/src/simulation/units.js +6 -4
  176. package/src/simulation/vehicle/contactResolution.js +116 -0
  177. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  178. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  179. package/src/simulation/vehicle/runoffResponse.js +88 -0
  180. package/src/simulation/vehicle/surfacePriority.js +28 -0
  181. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  182. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  183. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  184. package/src/simulation/vehicle/vehicleState.js +186 -0
  185. package/src/simulation/vehicle/wheelSurface.js +150 -0
  186. package/src/simulation/vehicleGeometry.js +1 -0
  187. package/src/simulation/vehiclePhysics.js +1 -104
  188. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  189. package/src/simulation/wheelSurface.js +1 -0
  190. package/src/styles.css +342 -42
  191. package/src/ui/bannerTemplates.js +69 -0
  192. package/src/ui/cameraControlsTemplate.js +20 -0
  193. package/src/ui/carOverviewTemplate.js +33 -0
  194. package/src/ui/componentTemplates.js +20 -426
  195. package/src/ui/raceCanvasTemplate.js +69 -0
  196. package/src/ui/raceControlStatusBanner.js +39 -0
  197. package/src/ui/raceControlsTemplate.js +36 -0
  198. package/src/ui/shellTemplate.js +6 -5
  199. package/src/ui/telemetryDrawerTemplate.js +55 -0
  200. package/src/ui/telemetryTemplates.js +136 -0
  201. package/src/ui/templateUtils.js +23 -0
  202. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,1146 +1,520 @@
1
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 { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
12
- import { simSpeedToKph, simUnitsToMeters } from './units.js';
13
- import { getCarCorners, integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
14
-
15
- const DEFAULT_TOTAL_LAPS = 10;
16
- export const FIXED_STEP = 1 / 60;
17
- const MIN_TOTAL_LAPS = 1;
18
- const MAX_COLLISION_CORRECTION = 4.5;
19
- const GRID_SLOT_SPACING = 82;
20
- const GRID_FIRST_SLOT_DISTANCE = -42;
21
- const GRID_LATERAL_OFFSET = 42;
22
- const TIMING_HISTORY_WINDOW_SECONDS = 18;
23
- const TIMING_HISTORY_MAX_SAMPLES = 720;
24
- const TELEMETRY_SECTOR_COUNT = 3;
25
-
26
- export const DEFAULT_RULES = {
27
- drsDetectionSeconds: 1,
28
- safetyCarSpeed: 46,
29
- safetyCarLeadDistance: 122,
30
- safetyCarGap: 128,
31
- collisionRestitution: 0.18,
32
- standingStart: true,
33
- startLightCount: 5,
34
- startLightInterval: 0.72,
35
- startLightsOutHold: 0.78,
36
- };
37
-
38
- function wrapProgress(value, length) {
39
- return wrapDistance(value, length);
40
- }
2
+ applyUnservedServicePenalty,
3
+ cancelPenaltyRecord,
4
+ createPenaltyEvent,
5
+ createPenaltyRecord,
6
+ servePenaltyRecord,
7
+ } from './rules/penaltyLedger.js';
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';
41
108
 
42
- function distanceForward(from, to, length) {
43
- return wrapProgress(to - from, length);
44
- }
109
+ export const FIXED_STEP = 1 / 60;
45
110
 
46
- function crossesDistance(previous, current, target, length) {
47
- const travelled = distanceForward(previous, current, length);
48
- if (travelled <= 0 || travelled > length / 2) return false;
49
- const targetOffset = distanceForward(previous, target, length);
50
- return targetOffset > 0 && targetOffset <= travelled + 0.001;
51
- }
111
+ export { DEFAULT_RULES };
52
112
 
53
- function isProgressInZone(track, progress, zone) {
54
- const wrapped = wrapProgress(progress, track.length);
55
- const start = wrapProgress(zone.start, track.length);
56
- const end = wrapProgress(zone.end, track.length);
57
- if (zone.end - zone.start >= track.length) return true;
58
- return end >= start
59
- ? wrapped >= start && wrapped <= end
60
- : wrapped >= start || wrapped <= end;
61
- }
113
+ export class F1RaceSimulation {
114
+ constructor(options = {}) {
115
+ initializeRaceSimulation(this, options);
116
+ }
62
117
 
63
- function createEmptySectorTimes() {
64
- return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
65
- }
118
+ assignPitLaneTeams() {
119
+ assignPitLaneTeamsState({ cars: this.cars, pitLane: this.track.pitLane });
120
+ }
66
121
 
67
- function createEmptySectorPerformance() {
68
- return {
69
- current: createEmptySectorTimes(),
70
- last: createEmptySectorTimes(),
71
- best: createEmptySectorTimes(),
72
- };
73
- }
122
+ initializePitStops() {
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,
131
+ });
132
+ }
74
133
 
75
- function getSectorLength(track) {
76
- return track.length / TELEMETRY_SECTOR_COUNT;
77
- }
134
+ setSafetyCar(deployed) {
135
+ setSafetyCarState(this, deployed);
136
+ }
78
137
 
79
- function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
80
- const positiveDistance = Math.max(0, raceDistance ?? 0);
81
- const lapIndex = Math.min(
82
- Math.floor(positiveDistance / track.length),
83
- Math.max(0, totalLaps - 1),
84
- );
85
- const lapProgress = positiveDistance >= track.length * totalLaps
86
- ? track.length
87
- : positiveDistance - lapIndex * track.length;
88
- const sectorLength = getSectorLength(track);
89
- const sectorIndex = Math.min(TELEMETRY_SECTOR_COUNT - 1, Math.floor(lapProgress / sectorLength));
90
- const sectorStart = sectorIndex * sectorLength;
91
-
92
- return {
93
- completedLaps: Math.floor(positiveDistance / track.length),
94
- currentLap: Math.max(1, lapIndex + 1),
95
- currentSector: sectorIndex + 1,
96
- currentSectorProgress: clamp((lapProgress - sectorStart) / sectorLength, 0, 1),
97
- };
98
- }
138
+ setRedFlag(deployed) {
139
+ setRedFlagState(this, deployed);
140
+ }
99
141
 
100
- function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
101
- const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
102
- return {
103
- ...position,
104
- currentLapStartedAt: currentTime,
105
- currentSectorStartedAt: currentTime,
106
- lastUpdatedAt: currentTime,
107
- currentLapTime: 0,
108
- currentSectorElapsed: 0,
109
- currentSectors: createEmptySectorTimes(),
110
- lastLapTime: null,
111
- bestLapTime: null,
112
- lastSectors: createEmptySectorTimes(),
113
- bestSectors: createEmptySectorTimes(),
114
- sectorPerformance: createEmptySectorPerformance(),
115
- };
116
- }
142
+ setPitLaneOpen(open) {
143
+ setPitLaneOpenState(this, open);
144
+ }
117
145
 
118
- function resetLapTelemetry(car, currentTime, track, totalLaps) {
119
- car.lapTelemetry = createLapTelemetry(track, currentTime, car.raceDistance, totalLaps);
120
- }
146
+ setCarState(id, partial) {
147
+ this.setCarStates({ [id]: partial });
148
+ }
121
149
 
122
- function finiteOrNull(value) {
123
- return Number.isFinite(value) ? value : null;
124
- }
150
+ setCarStates(partialsById = {}) {
151
+ const context = {
152
+ cars: this.cars,
153
+ computeLap: (raceDistance) => this.computeLap(raceDistance),
154
+ nearestDistanceOnRoute,
155
+ progressDelta,
156
+ raceControl: this.raceControl,
157
+ releaseRaceStart: () => this.releaseRaceStart(),
158
+ resetLapTelemetry,
159
+ resetTimingHistory,
160
+ resetTimingLineCrossings,
161
+ time: this.time,
162
+ totalLaps: this.totalLaps,
163
+ track: this.track,
164
+ };
165
+ let applied = false;
166
+ Object.entries(partialsById).forEach(([id, partial]) => {
167
+ const car = this.cars.find((item) => item.id === id);
168
+ if (!car) return;
169
+ applyExternalCarState(car, partial, context);
170
+ applied = true;
171
+ });
172
+ if (!applied) return;
173
+ this.recalculateRaceState({ updateDrs: false });
174
+ this.evaluateRaceFinish();
175
+ }
125
176
 
126
- function serializeSectorTimes(values) {
127
- return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
128
- }
177
+ setCarControls(id, controls) {
178
+ const car = this.cars.find((item) => item.id === id);
179
+ if (!car) return;
180
+ car.manualControls = controls;
181
+ }
129
182
 
130
- function serializeLapTelemetry(telemetry) {
131
- return {
132
- currentLap: telemetry.currentLap,
133
- currentSector: telemetry.currentSector,
134
- currentLapTime: finiteOrNull(telemetry.currentLapTime),
135
- currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
136
- currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
137
- currentSectors: serializeSectorTimes(telemetry.currentSectors),
138
- lastLapTime: finiteOrNull(telemetry.lastLapTime),
139
- bestLapTime: finiteOrNull(telemetry.bestLapTime),
140
- lastSectors: serializeSectorTimes(telemetry.lastSectors),
141
- bestSectors: serializeSectorTimes(telemetry.bestSectors),
142
- sectorPerformance: {
143
- current: serializeSectorPerformance(telemetry.sectorPerformance?.current),
144
- last: serializeSectorPerformance(telemetry.sectorPerformance?.last),
145
- best: serializeSectorPerformance(telemetry.sectorPerformance?.best),
146
- },
147
- completedLaps: telemetry.completedLaps,
148
- };
149
- }
183
+ clearCarControls(id) {
184
+ const car = this.cars.find((item) => item.id === id);
185
+ if (car) car.manualControls = null;
186
+ }
150
187
 
151
- function serializeSectorPerformance(values) {
152
- return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
153
- }
188
+ setAutomaticPitIntentEnabled(id, enabled) {
189
+ const car = this.cars.find((item) => item.id === id);
190
+ if (!car) return false;
191
+ car.automaticPitIntentEnabled = Boolean(enabled);
192
+ return true;
193
+ }
154
194
 
155
- function updateBestSector(telemetry, sectorIndex, sectorTime) {
156
- const previousBest = telemetry.bestSectors[sectorIndex];
157
- if (!Number.isFinite(previousBest) || sectorTime < previousBest) {
158
- telemetry.bestSectors[sectorIndex] = sectorTime;
195
+ getPitIntent(id) {
196
+ const car = this.cars.find((item) => item.id === id);
197
+ return normalizePitIntentForRace(car);
159
198
  }
160
- }
161
199
 
162
- function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
163
- const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
164
- telemetry.currentLap = position.currentLap;
165
- telemetry.currentSector = position.currentSector;
166
- telemetry.currentSectorProgress = position.currentSectorProgress;
167
- telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
168
- telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
169
- telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
170
- }
200
+ getPitTargetCompound(id) {
201
+ const car = this.cars.find((item) => item.id === id);
202
+ return car?.pitStop?.targetTire ?? null;
203
+ }
171
204
 
172
- function classifySectorPerformance(value, personalBest, overallBest) {
173
- if (!Number.isFinite(value)) return null;
174
- if (Number.isFinite(overallBest) && Math.abs(value - overallBest) <= 1e-6) return 'overall-best';
175
- if (Number.isFinite(personalBest) && Math.abs(value - personalBest) <= 1e-6) return 'personal-best';
176
- return 'slower';
177
- }
205
+ setPitIntent(id, intent, targetCompound = undefined) {
206
+ return setPitIntentForSimulation(this, id, intent, targetCompound);
207
+ }
178
208
 
179
- function updateSectorPerformance(cars) {
180
- const overallBestSectors = createEmptySectorTimes();
181
- cars.forEach((car) => {
182
- car.lapTelemetry?.bestSectors?.forEach((time, index) => {
183
- if (!Number.isFinite(time)) return;
184
- const previousBest = overallBestSectors[index];
185
- if (!Number.isFinite(previousBest) || time < previousBest) overallBestSectors[index] = time;
186
- });
187
- });
209
+ isCarInActivePitStop(car) {
210
+ return isCarInActivePitStopForSimulation(this, car);
211
+ }
188
212
 
189
- cars.forEach((car) => {
190
- if (!car.lapTelemetry) return;
191
- const telemetry = car.lapTelemetry;
192
- telemetry.sectorPerformance = {
193
- current: telemetry.currentSectors.map((time, index) => (
194
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
195
- )),
196
- last: telemetry.lastSectors.map((time, index) => (
197
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
198
- )),
199
- best: telemetry.bestSectors.map((time, index) => (
200
- classifySectorPerformance(time, telemetry.bestSectors[index], overallBestSectors[index])
201
- )),
202
- };
203
- });
204
- }
213
+ schedulePitStopAtNextEntry(car, stop) {
214
+ return schedulePitStopAtNextEntryForSimulation(this, car, stop);
215
+ }
205
216
 
206
- function updateLapTelemetry(car, previousRaceDistance, currentTime, track, totalLaps) {
207
- if (!car.lapTelemetry) resetLapTelemetry(car, currentTime, track, totalLaps);
208
-
209
- const telemetry = car.lapTelemetry;
210
- const currentRaceDistance = car.raceDistance ?? 0;
211
- const previousDistance = Math.max(0, previousRaceDistance ?? currentRaceDistance);
212
- const currentDistance = Math.max(0, currentRaceDistance);
213
- const travelled = currentDistance - previousDistance;
214
- const previousUpdateTime = Number.isFinite(telemetry.lastUpdatedAt) ? telemetry.lastUpdatedAt : currentTime;
215
- const elapsedTime = Math.max(0, currentTime - previousUpdateTime);
216
-
217
- if (!Number.isFinite(previousDistance) || !Number.isFinite(currentDistance) || travelled < -1e-3 || travelled > track.length / 2) {
218
- resetLapTelemetry(car, currentTime, track, totalLaps);
219
- return;
220
- }
221
-
222
- const sectorLength = getSectorLength(track);
223
- if (travelled > 1e-6) {
224
- const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
225
- const lastBoundary = Math.floor(currentDistance / sectorLength);
226
-
227
- for (let boundary = firstBoundary; boundary <= lastBoundary; boundary += 1) {
228
- const boundaryDistance = boundary * sectorLength;
229
- if (boundaryDistance <= previousDistance + 1e-3 || boundaryDistance > currentDistance + 1e-3) continue;
230
-
231
- const boundaryRatio = clamp((boundaryDistance - previousDistance) / travelled, 0, 1);
232
- const crossingTime = previousUpdateTime + boundaryRatio * elapsedTime;
233
- const sectorIndex = (boundary - 1) % TELEMETRY_SECTOR_COUNT;
234
- const sectorTime = Math.max(0, crossingTime - telemetry.currentSectorStartedAt);
235
-
236
- telemetry.currentSectors[sectorIndex] = sectorTime;
237
- updateBestSector(telemetry, sectorIndex, sectorTime);
238
-
239
- if (sectorIndex === TELEMETRY_SECTOR_COUNT - 1) {
240
- const lapTime = Math.max(0, crossingTime - telemetry.currentLapStartedAt);
241
- telemetry.lastLapTime = lapTime;
242
- telemetry.bestLapTime = Number.isFinite(telemetry.bestLapTime)
243
- ? Math.min(telemetry.bestLapTime, lapTime)
244
- : lapTime;
245
- telemetry.lastSectors = serializeSectorTimes(telemetry.currentSectors);
246
- telemetry.currentSectors = createEmptySectorTimes();
247
- telemetry.currentLapStartedAt = crossingTime;
248
- telemetry.completedLaps = Math.max(telemetry.completedLaps + 1, Math.min(Math.floor(boundaryDistance / track.length), totalLaps));
249
- }
250
-
251
- telemetry.currentSectorStartedAt = crossingTime;
252
- }
217
+ rearmCompletedPitStop(car, stop) {
218
+ return rearmCompletedPitStopForSimulation(this, car, stop);
253
219
  }
254
220
 
255
- syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps);
256
- telemetry.lastUpdatedAt = currentTime;
257
- }
221
+ canStartPitStop(car) {
222
+ return canStartPitStopForSimulation(this, car);
223
+ }
258
224
 
259
- function createCar(driver, index, random, track, { standingStart = false } = {}) {
260
- const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
261
- const start = pointAt(track, gridDistance);
262
- const offset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
263
- const position = offsetTrackPoint(start, offset);
264
- const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
265
- const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
266
- const personality = buildDriverPersonality(driver, index, racecraft, random);
267
- const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
268
- const vehicle = driver.vehicle ?? {};
269
-
270
- return {
271
- id: driver.id ?? `car-${index + 1}`,
272
- code: driver.code ?? `C${index + 1}`,
273
- timingCode: driver.timingCode ?? driver.code ?? `C${index + 1}`,
274
- driverNumber: driver.driverNumber ?? index + 1,
275
- icon: driver.icon ?? String(index + 1).padStart(2, '0'),
276
- raceName: driver.raceName ?? driver.code ?? `CAR${index + 1}`,
277
- name: driver.name ?? `Car ${index + 1}`,
278
- color: driver.color ?? '#e10600',
279
- team: driver.team ? { ...driver.team } : null,
280
- tire: driver.tire ?? ['M', 'H', 'S'][index % 3],
281
- index,
282
- x: position.x,
283
- y: position.y,
284
- previousX: position.x,
285
- previousY: position.y,
286
- heading: start.heading,
287
- previousHeading: start.heading,
288
- steeringAngle: 0,
289
- yawRate: 0,
290
- turnRadius: Infinity,
291
- speed: standingStart ? 0 : launchSpeed,
292
- throttle: 0,
293
- brake: 0,
294
- vehicleId: vehicle.id ?? null,
295
- vehicleName: vehicle.name ?? null,
296
- vehicleRatings: vehicle.ratings ? { ...vehicle.ratings } : null,
297
- mass: vehicle.mass ?? 798 + seededRange(random, -5, 5),
298
- powerNewtons: vehicle.powerNewtons ?? 43000 * pace,
299
- brakeNewtons: vehicle.brakeNewtons ?? 59000,
300
- dragCoefficient: vehicle.dragCoefficient ?? 0.33 + seededRange(random, -0.026, 0.026),
301
- downforceCoefficient: vehicle.downforceCoefficient ?? 6.1 + seededRange(random, -0.18, 0.18),
302
- tireGrip: vehicle.tireGrip ?? 2.22 + racecraft * 0.28 + seededRange(random, -0.03, 0.03),
303
- tireCare: vehicle.tireCare ?? 1,
304
- pace,
305
- racecraft,
306
- personality,
307
- aggression: personality.baseAggression,
308
- gridLocked: standingStart,
309
- gridDistance,
310
- gridOffset: offset,
311
- desiredOffset: offset,
312
- progress: start.distance,
313
- raceDistance: gridDistance,
314
- lap: 1,
315
- rank: index + 1,
316
- gapAhead: Infinity,
317
- gapAheadSeconds: Infinity,
318
- leaderGapSeconds: 0,
319
- intervalAheadSeconds: Infinity,
320
- drsEligible: false,
321
- drsActive: false,
322
- drsZoneId: null,
323
- drsZoneEnabled: false,
324
- timingHistory: [],
325
- lapTelemetry: createLapTelemetry(track, 0, gridDistance),
326
- drsDetection: {},
327
- canAttack: true,
328
- trackState: start,
329
- contactCooldown: 0,
330
- tireEnergy: 100,
331
- };
332
- }
225
+ shouldStartPitStop(car) {
226
+ return shouldStartPitStopForSimulation(this, car);
227
+ }
333
228
 
334
- function normalizeTotalLaps(value) {
335
- const numeric = Number(value);
336
- if (!Number.isFinite(numeric)) return MIN_TOTAL_LAPS;
337
- return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
338
- }
229
+ updateAutomaticPitIntent(car) {
230
+ return updateAutomaticPitIntentForSimulation(this, car);
231
+ }
339
232
 
340
- function projectOntoAxis(points, axis) {
341
- let min = Infinity;
342
- let max = -Infinity;
343
- points.forEach((point) => {
344
- const projection = point.x * axis.x + point.y * axis.y;
345
- min = Math.min(min, projection);
346
- max = Math.max(max, projection);
347
- });
348
- return { min, max };
349
- }
233
+ isPitLaneOpenForStops() {
234
+ return isPitLaneOpenForStopsForSimulation(this);
235
+ }
350
236
 
351
- function overlapOnAxis(a, b, axis) {
352
- const first = projectOntoAxis(a, axis);
353
- const second = projectOntoAxis(b, axis);
354
- return Math.min(first.max, second.max) - Math.max(first.min, second.min);
355
- }
237
+ pitEntryLateThresholdDistance() {
238
+ return pitEntryLateThresholdDistanceForSimulation();
239
+ }
356
240
 
357
- function detectObbCollision(a, b) {
358
- const aCorners = getCarCorners(a);
359
- const bCorners = getCarCorners(b);
360
- const axes = [
361
- normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
362
- normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
363
- normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
364
- normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
365
- ];
366
-
367
- let minimumOverlap = Infinity;
368
- let minimumAxis = null;
369
-
370
- for (const axis of axes) {
371
- const overlap = overlapOnAxis(aCorners, bCorners, axis);
372
- if (overlap <= 0) return null;
373
- if (overlap < minimumOverlap) {
374
- minimumOverlap = overlap;
375
- minimumAxis = axis;
376
- }
241
+ getPitStopBox(stop) {
242
+ return getPitStopBoxForSimulation(this, stop);
377
243
  }
378
244
 
379
- const direction = { x: b.x - a.x, y: b.y - a.y };
380
- if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
381
- minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
245
+ isPitServiceBusy(car, box) {
246
+ return isPitServiceBusyForSimulation(this, car, box);
382
247
  }
383
248
 
384
- return { axis: minimumAxis, depth: minimumOverlap };
385
- }
249
+ isPitServiceAreaOccupied(candidate, box) {
250
+ return isPitServiceAreaOccupiedForSimulation(this, candidate, box);
251
+ }
386
252
 
387
- function detectLongitudinalCollision(a, b) {
388
- const longitudinalGap = b.raceDistance - a.raceDistance;
389
- const headingDelta = Math.abs(normalizeAngle(a.heading - b.heading));
390
- const blendedHeading = a.heading + normalizeAngle(b.heading - a.heading) * 0.5;
391
- let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
392
- const direction = { x: b.x - a.x, y: b.y - a.y };
393
- const physicalLongitudinalGap = dot(direction, axis);
394
- if (physicalLongitudinalGap < 0) axis = { x: -axis.x, y: -axis.y };
395
-
396
- const physicalLongitudinalSeparation = Math.abs(physicalLongitudinalGap);
397
- const lateralGap = Math.abs(direction.x * -axis.y + direction.y * axis.x);
398
- const requiredGap = VEHICLE_LIMITS.carLength * 0.96;
399
-
400
- if (Math.abs(longitudinalGap) > VEHICLE_LIMITS.carLength * 1.45) return null;
401
- if (physicalLongitudinalSeparation >= requiredGap) return null;
402
- if (lateralGap > VEHICLE_LIMITS.carWidth * 0.82) return null;
403
- if (headingDelta > 0.58) return null;
404
-
405
- return {
406
- axis,
407
- depth: requiredGap - physicalLongitudinalSeparation,
408
- longitudinal: true,
409
- };
410
- }
253
+ isPitServiceQueueOccupied(car, box) {
254
+ return isPitServiceQueueOccupiedForSimulation(this, car, box);
255
+ }
411
256
 
412
- function forwardVector(car) {
413
- return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
414
- }
257
+ getPitBoxRaceDistance(stop, box) {
258
+ return getPitBoxRaceDistanceForSimulation(this, stop, box);
259
+ }
415
260
 
416
- function dot(a, b) {
417
- return a.x * b.x + a.y * b.y;
418
- }
261
+ startPitStop(car) {
262
+ return startPitStopForSimulation(this, car);
263
+ }
419
264
 
420
- function normalizeVector(vector) {
421
- const length = Math.hypot(vector.x, vector.y) || 1;
422
- return { x: vector.x / length, y: vector.y / length };
423
- }
265
+ beginPitQueue(car, box) {
266
+ return beginPitQueueForSimulation(this, car, box);
267
+ }
424
268
 
425
- function progressDelta(a, b, trackLength) {
426
- let delta = a - b;
427
- if (delta < -trackLength / 2) delta += trackLength;
428
- if (delta > trackLength / 2) delta -= trackLength;
429
- return delta;
430
- }
269
+ releasePitQueue(car, box, { fromCurrent = false } = {}) {
270
+ return releasePitQueueForSimulation(this, car, box, { fromCurrent });
271
+ }
431
272
 
432
- function trimTimingHistory(history, currentTime) {
433
- const cutoff = currentTime - TIMING_HISTORY_WINDOW_SECONDS;
434
- while (history.length > 2 && (history[0].time < cutoff || history.length > TIMING_HISTORY_MAX_SAMPLES)) {
435
- history.shift();
273
+ beginPitService(car, box) {
274
+ return beginPitServiceForSimulation(this, car, box);
436
275
  }
437
- }
438
276
 
439
- function resetTimingHistory(car, currentTime) {
440
- car.timingHistory = [{ time: currentTime, raceDistance: car.raceDistance }];
441
- }
277
+ beginPitPenaltyService(car) {
278
+ return beginPitPenaltyServiceForSimulation(this, car);
279
+ }
442
280
 
443
- function recordTimingSample(car, currentTime) {
444
- if (!Number.isFinite(car.raceDistance)) return;
445
- if (!Array.isArray(car.timingHistory)) {
446
- resetTimingHistory(car, currentTime);
447
- return;
281
+ calculatePitServiceProfile(car) {
282
+ return calculatePitServiceProfileForSimulation(this, car);
448
283
  }
449
284
 
450
- const previous = car.timingHistory[car.timingHistory.length - 1];
451
- if (
452
- previous &&
453
- Math.abs(previous.time - currentTime) <= 1e-6 &&
454
- Math.abs(previous.raceDistance - car.raceDistance) <= 1e-3
455
- ) {
456
- return;
285
+ beginTireService(car) {
286
+ return beginTireServiceForSimulation(this, car);
457
287
  }
458
288
 
459
- car.timingHistory.push({ time: currentTime, raceDistance: car.raceDistance });
460
- trimTimingHistory(car.timingHistory, currentTime);
461
- }
289
+ completePitPenaltyService(car) {
290
+ return completePitPenaltyServiceForSimulation(this, car);
291
+ }
462
292
 
463
- function interpolateTimeAtDistance(history, targetDistance) {
464
- if (!Array.isArray(history) || history.length < 2) return null;
293
+ getPitServicePenalties(driverId) {
294
+ return getPitServicePenaltiesForSimulation(this, driverId);
295
+ }
465
296
 
466
- for (let index = history.length - 1; index > 0; index -= 1) {
467
- const current = history[index];
468
- const previous = history[index - 1];
469
- if (targetDistance < previous.raceDistance || targetDistance > current.raceDistance) continue;
297
+ completePitService(car) {
298
+ return completePitServiceForSimulation(this, car);
299
+ }
470
300
 
471
- const coveredDistance = current.raceDistance - previous.raceDistance;
472
- if (coveredDistance <= 1e-6) return current.time;
473
- const ratio = (targetDistance - previous.raceDistance) / coveredDistance;
474
- return previous.time + (current.time - previous.time) * ratio;
301
+ finishPitExit(car) {
302
+ return finishPitExitForSimulation(this, car);
475
303
  }
476
304
 
477
- return null;
478
- }
305
+ applyPitRoutePosition(car, delta) {
306
+ return applyPitRoutePositionForSimulation(this, car, delta);
307
+ }
479
308
 
480
- function estimateGapAheadSeconds(ahead, car, currentTime) {
481
- if (!ahead) return Infinity;
309
+ advancePitStopCar(car, delta) {
310
+ return advancePitStopCarForSimulation(this, car, delta);
311
+ }
482
312
 
483
- const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
484
- if (Number.isFinite(crossingTime)) {
485
- return Math.max(0, currentTime - crossingTime);
313
+ step(dt) {
314
+ runRaceStep(this, dt);
486
315
  }
487
316
 
488
- const speedReference = Math.max((ahead.speed + car.speed) * 0.5, 1);
489
- return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
490
- }
317
+ recordPenalty(penalty) {
318
+ const car = this.cars.find((item) => item.id === penalty.driverId);
319
+ const entry = createPenaltyRecord({
320
+ sequence: this.nextPenaltyId,
321
+ time: this.time,
322
+ lap: this.computeLap(car?.raceDistance ?? 0),
323
+ penalty,
324
+ });
325
+ this.nextPenaltyId += 1;
326
+ this.penalties.push(entry);
327
+ if (entry.gridDrop > 0 && this.raceControl.mode === 'pre-start') {
328
+ this.applyGridDrop(entry.driverId, entry.gridDrop);
329
+ }
330
+ this.events.unshift(createPenaltyEvent(entry));
331
+ return entry;
332
+ }
491
333
 
492
- function recordDrsDetection(car, zoneId, currentTime) {
493
- const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
494
- const next = { passage: previous.passage + 1, time: currentTime };
495
- car.drsDetection = {
496
- ...(car.drsDetection ?? {}),
497
- [zoneId]: next,
498
- };
499
- return next;
500
- }
334
+ servePenalty(penaltyId) {
335
+ const penalty = this.penalties.find((entry) => entry.id === penaltyId);
336
+ const result = servePenaltyRecord(penalty, this.time);
337
+ if (result) {
338
+ this.events.unshift(createPenaltyServedEvent(result, this.time));
339
+ }
340
+ return result;
341
+ }
501
342
 
502
- function serializeCar(car, rank) {
503
- return {
504
- id: car.id,
505
- code: car.code,
506
- timingCode: car.timingCode,
507
- driverNumber: car.driverNumber,
508
- icon: car.icon,
509
- raceName: car.raceName,
510
- name: car.name,
511
- color: car.color,
512
- team: car.team ? { ...car.team } : null,
513
- tire: car.tire,
514
- personality: { ...car.personality },
515
- aggression: car.aggression,
516
- aggressionPercent: Math.round((car.aggression ?? 0) * 100),
517
- setup: {
518
- vehicleId: car.vehicleId,
519
- vehicleName: car.vehicleName,
520
- vehicleRatings: car.vehicleRatings ? { ...car.vehicleRatings } : null,
521
- maxSpeedKph: simSpeedToKph(VEHICLE_LIMITS.maxSpeed),
522
- powerUnitKn: car.powerNewtons / 1000,
523
- brakeSystemKn: car.brakeNewtons / 1000,
524
- dragCoefficient: car.dragCoefficient,
525
- downforceCoefficient: car.downforceCoefficient,
526
- tireGrip: car.tireGrip,
527
- tireCare: car.tireCare,
528
- massKg: car.mass,
529
- pace: car.pace,
530
- racecraft: car.racecraft,
531
- },
532
- rank,
533
- classifiedRank: car.classifiedRank ?? rank,
534
- finished: Boolean(car.finished),
535
- finishTime: car.finishTime ?? null,
536
- previousX: car.previousX ?? car.x,
537
- previousY: car.previousY ?? car.y,
538
- x: car.x,
539
- y: car.y,
540
- previousHeading: car.previousHeading ?? car.heading,
541
- heading: car.heading,
542
- steeringAngle: car.steeringAngle,
543
- yawRate: car.yawRate,
544
- turnRadius: car.turnRadius,
545
- speed: car.speed,
546
- speedKph: simSpeedToKph(car.speed),
547
- throttle: car.throttle,
548
- brake: car.brake,
549
- lateralAcceleration: car.lateralAcceleration,
550
- progress: car.progress,
551
- raceDistance: car.raceDistance,
552
- distanceMeters: simUnitsToMeters(car.raceDistance),
553
- lap: car.lap,
554
- lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
555
- gapAhead: car.gapAhead,
556
- gapAheadMeters: Number.isFinite(car.gapAhead) ? simUnitsToMeters(car.gapAhead) : Infinity,
557
- gapAheadSeconds: car.gapAheadSeconds,
558
- intervalAheadSeconds: car.intervalAheadSeconds,
559
- leaderGapSeconds: car.leaderGapSeconds,
560
- drsEligible: car.drsEligible,
561
- drsActive: car.drsActive,
562
- drsZoneId: car.drsZoneId,
563
- drsZoneEnabled: car.drsZoneEnabled,
564
- canAttack: car.canAttack,
565
- signedOffset: car.trackState?.signedOffset ?? 0,
566
- crossTrackError: car.trackState?.crossTrackError ?? 0,
567
- surface: car.trackState?.surface ?? 'track',
568
- contactCooldown: car.contactCooldown,
569
- tireEnergy: car.tireEnergy,
570
- positionSource: 'integrated-vehicle',
571
- };
572
- }
343
+ cancelPenalty(penaltyId) {
344
+ const penalty = this.penalties.find((entry) => entry.id === penaltyId);
345
+ const result = cancelPenaltyRecord(penalty, this.time);
346
+ if (result) {
347
+ this.events.unshift(createPenaltyCancelledEvent(result, this.time));
348
+ }
349
+ return result;
350
+ }
573
351
 
574
- export class F1RaceSimulation {
575
- constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
576
- this.seed = seed;
577
- this.random = createMulberry32(seed);
578
- const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
579
- this.track = buildTrackModel(trackDefinition);
580
- this.trackSeed = this.track.seed ?? trackSeed;
581
- this.rules = { ...DEFAULT_RULES, ...rules };
582
- this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
583
- this.totalLaps = normalizeTotalLaps(totalLaps);
584
- this.time = 0;
585
- this.events = [];
586
- this.raceControl = {
587
- mode: this.rules.standingStart === false ? 'green' : 'pre-start',
588
- frozenOrder: null,
589
- finished: false,
590
- finishedAt: null,
591
- winnerId: null,
592
- classification: [],
593
- finishOrder: [],
594
- start: {
595
- lightCount: this.rules.startLightCount,
596
- lightsLit: 0,
597
- lightsOutAt: this.startLightsOutAt,
598
- released: this.rules.standingStart === false,
599
- releasedAt: this.rules.standingStart === false ? 0 : null,
600
- },
601
- };
602
- const safetyCarStart = pointAt(this.track, this.rules.safetyCarLeadDistance);
603
- this.safetyCar = {
604
- deployed: false,
605
- progress: this.rules.safetyCarLeadDistance,
606
- speed: this.rules.safetyCarSpeed,
607
- previousX: safetyCarStart.x,
608
- previousY: safetyCarStart.y,
609
- previousHeading: safetyCarStart.heading,
610
- x: safetyCarStart.x,
611
- y: safetyCarStart.y,
612
- heading: safetyCarStart.heading,
613
- };
614
- this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
615
- standingStart: this.raceControl.mode === 'pre-start',
616
- }));
617
- this.recalculateRaceState({ updateDrs: false });
618
- this.cars.forEach((car) => resetTimingHistory(car, this.time));
619
- this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
352
+ getDriverPenaltySeconds(driverId) {
353
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).seconds;
620
354
  }
621
355
 
622
- setSafetyCar(deployed) {
623
- const next = Boolean(deployed);
624
- if (this.raceControl.finished) return;
625
- if (next && this.raceControl.mode === 'pre-start') return;
626
- if (next === this.safetyCar.deployed) return;
627
- const ordered = this.orderedCars();
628
- this.safetyCar.deployed = next;
629
- this.raceControl.mode = next ? 'safety-car' : 'green';
630
- this.raceControl.frozenOrder = next ? ordered.map((car) => car.id) : null;
631
- if (next) {
632
- const leader = ordered[0];
633
- const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
634
- if (this.safetyCar.progress < safetyCarProgress) {
635
- this.moveSafetyCarTo(safetyCarProgress);
636
- }
637
- this.cars.forEach((car) => {
638
- car.desiredOffset = 0;
639
- car.drsActive = false;
640
- car.drsEligible = false;
641
- car.drsZoneId = null;
642
- car.drsZoneEnabled = false;
643
- });
644
- }
645
- this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
356
+ getDriverPositionDrop(driverId) {
357
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).positionDrop;
646
358
  }
647
359
 
648
- setCarState(id, partial) {
649
- const car = this.cars.find((item) => item.id === id);
650
- if (!car) return;
651
- Object.assign(car, partial);
652
- if (
653
- partial.x != null ||
654
- partial.y != null ||
655
- partial.progress != null ||
656
- partial.raceDistance != null
657
- ) {
658
- car.gridLocked = false;
659
- if (this.raceControl.mode === 'pre-start' && this.cars.every((item) => !item.gridLocked)) {
660
- this.releaseRaceStart();
661
- }
662
- }
663
- car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
664
- car.heading = normalizeAngle(car.heading);
665
- car.trackState = nearestTrackState(this.track, car);
666
- const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
667
- car.raceDistance = (car.raceDistance ?? car.trackState.distance) + delta;
668
- car.progress = car.trackState.distance;
669
- car.lap = this.computeLap(car.raceDistance);
670
- car.drsZoneId = null;
671
- car.drsZoneEnabled = false;
672
- car.drsActive = false;
673
- car.drsEligible = false;
674
- car.drsDetection = {};
675
- resetTimingHistory(car, this.time);
676
- resetLapTelemetry(car, this.time, this.track, this.totalLaps);
677
- this.recalculateRaceState({ updateDrs: false });
678
- this.evaluateRaceFinish();
360
+ isDriverDisqualified(driverId) {
361
+ return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).disqualified;
679
362
  }
680
363
 
681
- setCarControls(id, controls) {
682
- const car = this.cars.find((item) => item.id === id);
683
- if (!car) return;
684
- car.manualControls = controls;
364
+ getPenaltyStatsByDriver() {
365
+ return buildPenaltyStatsByDriver(this.penalties);
685
366
  }
686
367
 
687
- clearCarControls(id) {
688
- const car = this.cars.find((item) => item.id === id);
689
- if (car) car.manualControls = null;
368
+ applyGridDrop(driverId, positions) {
369
+ applyGridDropForSimulation(this, driverId, positions);
690
370
  }
691
371
 
692
- step(dt) {
693
- const delta = clamp(dt, 0, 1 / 20);
694
- if (!Number.isFinite(delta) || delta <= 0) return;
372
+ reviewCollision(first, second, collision) {
373
+ reviewCollisionForSimulation(this, first, second, collision);
374
+ }
695
375
 
696
- this.time += delta;
697
- this.events = [];
698
- this.updateStartSequence();
699
- this.recalculateRaceState({ updateDrs: false });
376
+ reviewTireRequirement(car) {
377
+ if (this.stewardState.tireRequirement[car.id]) return;
378
+ this.stewardState.tireRequirement[car.id] = true;
379
+ reviewTireRequirementForSimulation(this, car);
380
+ }
700
381
 
701
- if (this.raceControl.mode === 'pre-start' && this.cars.every((car) => car.gridLocked)) {
702
- this.holdGridCars();
703
- this.recalculateRaceState({ updateDrs: false });
704
- return;
705
- }
382
+ reviewTrackLimits() {
383
+ reviewTrackLimitsForSimulation(this);
384
+ }
706
385
 
707
- this.updateSafetyCar(delta);
708
-
709
- const orderedCars = this.orderedCars();
710
- const raceContext = this.driverRaceContext(orderedCars);
711
- orderedCars.forEach((car, index) => {
712
- car.previousX = car.x;
713
- car.previousY = car.y;
714
- car.previousHeading = car.heading;
715
- car.previousProgress = car.progress;
716
- const controls = decideDriverControls({
717
- car,
718
- orderIndex: index,
719
- race: raceContext,
720
- });
721
- integrateVehiclePhysics(car, controls, delta);
722
- this.applyRunoffResponse(car);
723
- car.contactCooldown = Math.max(0, car.contactCooldown - delta);
724
- });
386
+ reviewPitLaneSpeeding() {
387
+ reviewPitLaneSpeedingForSimulation(this);
388
+ }
725
389
 
726
- this.resolveCollisions();
727
- this.recalculateRaceState();
728
- this.evaluateRaceFinish();
390
+ vehicleSnapshotDependencies() {
391
+ return createVehicleSnapshotDependencies(this);
729
392
  }
730
393
 
731
394
  snapshot() {
732
- const ordered = this.orderedCars();
733
- return {
734
- time: this.time,
735
- world: WORLD,
736
- track: this.track,
737
- totalLaps: this.totalLaps,
738
- raceControl: {
739
- mode: this.raceControl.mode,
740
- finished: this.raceControl.finished,
741
- finishedAt: this.raceControl.finishedAt,
742
- winner: this.getRaceWinnerSnapshot(),
743
- classification: this.raceControl.classification.map((entry) => ({ ...entry })),
744
- start: {
745
- ...this.raceControl.start,
746
- visible: this.raceControl.mode === 'pre-start' ||
747
- (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
748
- },
749
- },
750
- safetyCar: { ...this.safetyCar },
751
- rules: this.rules,
752
- events: [...this.events],
753
- cars: ordered.map((car, index) => serializeCar(car, index + 1)),
754
- };
395
+ return snapshotRace(this);
755
396
  }
756
397
 
757
- orderedCars() {
758
- if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
759
- const byId = new Map(this.cars.map((car) => [car.id, car]));
760
- return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
761
- }
762
-
763
- return [...this.cars].sort((a, b) => {
764
- const delta = b.raceDistance - a.raceDistance;
765
- return delta === 0 ? a.index - b.index : delta;
766
- });
398
+ snapshotRender() {
399
+ return snapshotRaceRender(this);
767
400
  }
768
401
 
769
- driverRaceContext(orderedCars = this.orderedCars()) {
770
- return {
771
- track: this.track,
772
- cars: this.cars,
773
- orderedCars,
774
- safetyCar: this.safetyCar,
775
- rules: this.rules,
776
- };
402
+ snapshotObservation() {
403
+ return snapshotRaceObservation(this);
777
404
  }
778
405
 
779
- computeAggression(car, orderIndex = Math.max(0, (car.rank ?? 1) - 1)) {
780
- const personality = car.personality ?? { baseAggression: 0.5, riskTolerance: 0.5, patience: 0.5 };
781
- if (this.safetyCar.deployed || car.canAttack === false) {
782
- return clamp(personality.baseAggression * 0.62, 0.08, 0.62);
783
- }
406
+ snapshotTraining() {
407
+ return snapshotRaceTraining(this);
408
+ }
784
409
 
785
- const fieldDepth = Math.max(1, this.cars.length - 1);
786
- const positionPressure = clamp(orderIndex / fieldDepth, 0, 1);
787
- const gapPressure = Number.isFinite(car.gapAhead) ? clamp((230 - car.gapAhead) / 230, 0, 1) : 0;
788
- const tireConfidence = clamp(((car.tireEnergy ?? 100) - 42) / 58, 0, 1);
789
- const patienceDamping = (1 - gapPressure) * personality.patience * 0.08;
410
+ consumeStepEvents() {
411
+ return [...this.events];
412
+ }
790
413
 
791
- return clamp(
792
- personality.baseAggression
793
- + positionPressure * 0.26
794
- + gapPressure * (0.1 + personality.riskTolerance * 0.08)
795
- - (1 - tireConfidence) * 0.1
796
- - patienceDamping,
797
- 0.08,
798
- 1,
799
- );
414
+ orderedCars() {
415
+ return orderedCarsForSimulation(this);
800
416
  }
801
417
 
802
- updateStartSequence() {
803
- const start = this.raceControl.start;
804
- if (this.raceControl.mode !== 'pre-start' || !start || start.released) return;
418
+ driverRaceContext(orderedCars = this.orderedCars()) {
419
+ return driverRaceContextForSimulation(this, orderedCars);
420
+ }
805
421
 
806
- if (this.time >= start.lightsOutAt) {
807
- this.releaseRaceStart();
808
- this.events.unshift({ type: 'start-lights-out', at: this.time });
809
- return;
810
- }
422
+ computeAggression(car, orderIndex = Math.max(0, (car.rank ?? 1) - 1)) {
423
+ return computeAggressionForSimulation(this, car, orderIndex);
424
+ }
811
425
 
812
- start.lightsLit = clamp(
813
- Math.floor((this.time + Number.EPSILON) / this.rules.startLightInterval),
814
- 0,
815
- start.lightCount,
816
- );
426
+ updateStartSequence() {
427
+ return updateStartSequenceForSimulation(this);
817
428
  }
818
429
 
819
430
  releaseRaceStart() {
820
- const start = this.raceControl.start;
821
- this.raceControl.mode = 'green';
822
- start.lightsLit = 0;
823
- start.released = true;
824
- start.releasedAt = this.time;
825
- this.cars.forEach((car) => {
826
- const wasGridLocked = car.gridLocked;
827
- car.gridLocked = false;
828
- const state = nearestTrackState(this.track, car, car.gridDistance);
829
- car.trackState = state;
830
- car.progress = state.distance;
831
- if (wasGridLocked) car.raceDistance = car.gridDistance;
832
- car.previousX = car.x;
833
- car.previousY = car.y;
834
- car.previousHeading = car.heading;
835
- });
431
+ return releaseRaceStartForSimulation(this);
836
432
  }
837
433
 
838
434
  holdGridCars() {
839
- this.cars.forEach((car) => {
840
- if (!car.gridLocked) return;
841
- const gridPoint = pointAt(this.track, car.gridDistance);
842
- const position = offsetTrackPoint(gridPoint, car.gridOffset);
843
- car.previousX = position.x;
844
- car.previousY = position.y;
845
- car.previousHeading = gridPoint.heading;
846
- car.x = position.x;
847
- car.y = position.y;
848
- car.heading = gridPoint.heading;
849
- car.speed = 0;
850
- car.throttle = 0;
851
- car.brake = 1;
852
- car.steeringAngle = 0;
853
- car.yawRate = 0;
854
- car.turnRadius = Infinity;
855
- car.progress = gridPoint.distance;
856
- car.raceDistance = car.gridDistance;
857
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
858
- });
435
+ return holdGridCarsForSimulation(this);
859
436
  }
860
437
 
861
438
  updateSafetyCar(dt) {
862
- if (!this.safetyCar.deployed) return;
863
- const leader = this.orderedCars()[0];
864
- const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
865
- const progress = Math.max(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress);
866
- this.moveSafetyCarTo(progress);
439
+ updateSafetyCarState(this, dt);
867
440
  }
868
441
 
869
442
  moveSafetyCarTo(progress) {
870
- const point = pointAt(this.track, progress);
871
- this.safetyCar.previousX = this.safetyCar.x;
872
- this.safetyCar.previousY = this.safetyCar.y;
873
- this.safetyCar.previousHeading = this.safetyCar.heading;
874
- this.safetyCar.progress = progress;
875
- this.safetyCar.x = point.x;
876
- this.safetyCar.y = point.y;
877
- this.safetyCar.heading = point.heading;
443
+ moveSafetyCarToState(this, progress);
878
444
  }
879
445
 
880
446
  applyRunoffResponse(car) {
881
- const state = nearestTrackState(this.track, car, car.progress);
882
- const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
883
- const overshoot = Math.abs(state.signedOffset) - signedLimit;
884
- if (overshoot <= 0) {
885
- car.trackState = state;
886
- return;
887
- }
888
-
889
- const side = Math.sign(state.signedOffset) || 1;
890
- car.x -= state.normalX * side * overshoot;
891
- car.y -= state.normalY * side * overshoot;
892
- car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
893
- car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
894
- car.trackState = nearestTrackState(this.track, car, state.distance);
447
+ applyRunoffResponseForSimulation(this, car);
895
448
  }
896
449
 
897
450
  recalculateRaceState({ updateDrs = true } = {}) {
898
- this.cars.forEach((car) => {
899
- const previousRaceDistance = car.raceDistance;
900
- if (car.gridLocked) {
901
- const gridPoint = pointAt(this.track, car.gridDistance);
902
- car.trackState = nearestTrackState(this.track, car, car.gridDistance);
903
- car.progress = gridPoint.distance;
904
- car.raceDistance = car.gridDistance;
905
- car.lap = 1;
906
- resetLapTelemetry(car, this.time, this.track, this.totalLaps);
907
- return;
908
- }
909
-
910
- car.trackState = nearestTrackState(this.track, car, car.progress);
911
- const previousProgress = car.progress ?? car.trackState.distance;
912
- const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
913
- car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
914
- car.progress = car.trackState.distance;
915
- car.lap = this.computeLap(car.raceDistance);
916
- updateLapTelemetry(car, previousRaceDistance, this.time, this.track, this.totalLaps);
917
- });
918
-
919
- updateSectorPerformance(this.cars);
920
- this.cars.forEach((car) => recordTimingSample(car, this.time));
921
-
922
- const ordered = this.orderedCars();
923
- ordered.forEach((car, index) => {
924
- const ahead = ordered[index - 1];
925
- const gap = ahead ? ahead.raceDistance - car.raceDistance : Infinity;
926
- car.rank = index + 1;
927
- car.gapAhead = gap;
928
- car.gapAheadSeconds = Number.isFinite(gap) ? estimateGapAheadSeconds(ahead, car, this.time) : Infinity;
929
- car.intervalAheadSeconds = car.gapAheadSeconds;
930
- car.leaderGapSeconds = ahead ? ahead.leaderGapSeconds + car.gapAheadSeconds : 0;
931
- car.canAttack = !this.safetyCar.deployed && !car.finished;
932
- car.aggression = this.computeAggression(car, index);
933
- if (updateDrs) this.updateDrsLatch(car, ahead, index);
934
- });
935
- this.evaluateRaceFinish();
451
+ return recalculateRaceStateForSimulation(this, { updateDrs });
936
452
  }
937
453
 
938
454
  evaluateRaceFinish() {
939
- if (this.raceControl.finished) return;
940
- if (this.raceControl.mode === 'pre-start') return;
941
-
942
- const ordered = this.orderedCars();
943
- const newlyFinished = ordered.filter((car) => !car.finished && car.raceDistance >= this.finishDistance);
944
- if (newlyFinished.length === 0) return;
945
-
946
- newlyFinished.forEach((car) => {
947
- car.finished = true;
948
- car.finishTime = this.time;
949
- car.classifiedRank = this.raceControl.finishOrder.length + 1;
950
- this.raceControl.finishOrder.push(car.id);
951
- if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
952
- car.drsActive = false;
953
- car.drsEligible = false;
954
- car.drsZoneId = null;
955
- car.drsZoneEnabled = false;
956
- car.canAttack = false;
957
- this.events.unshift({
958
- type: 'car-finish',
959
- at: this.time,
960
- carId: car.id,
961
- rank: car.classifiedRank,
962
- winnerId: this.raceControl.winnerId,
963
- });
964
- });
455
+ evaluateRaceFinishForSimulation(this);
456
+ }
965
457
 
966
- if (!this.cars.every((car) => car.finished)) return;
967
-
968
- const classification = this.buildClassificationFromFinishOrder();
969
- this.raceControl.mode = 'safety-car';
970
- this.raceControl.finished = true;
971
- this.raceControl.finishedAt = this.time;
972
- this.raceControl.classification = classification;
973
- this.raceControl.frozenOrder = classification.map((entry) => entry.id);
974
- this.safetyCar.deployed = true;
975
- const leader = this.cars.find((car) => car.id === this.raceControl.winnerId) ?? ordered[0];
976
- const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
977
- if (this.safetyCar.progress < safetyCarProgress) {
978
- this.moveSafetyCarTo(safetyCarProgress);
979
- }
980
- this.cars.forEach((car) => {
981
- const classified = classification.find((entry) => entry.id === car.id);
982
- car.classifiedRank = classified?.rank ?? car.rank;
983
- car.desiredOffset = 0;
984
- car.drsActive = false;
985
- car.drsEligible = false;
986
- car.drsZoneId = null;
987
- car.drsZoneEnabled = false;
988
- car.canAttack = false;
989
- });
990
- this.events.unshift({
991
- type: 'race-finish',
992
- at: this.time,
993
- winnerId: this.raceControl.winnerId,
994
- classification: classification.map((entry) => ({ id: entry.id, rank: entry.rank })),
995
- });
458
+ applyOutstandingServicePenalties() {
459
+ applyOutstandingServicePenaltiesState(this, applyUnservedServicePenalty);
996
460
  }
997
461
 
998
462
  buildClassificationFromFinishOrder() {
999
- const byId = new Map(this.cars.map((car) => [car.id, car]));
1000
- const ordered = this.raceControl.finishOrder
1001
- .map((id) => byId.get(id))
1002
- .filter(Boolean);
1003
- return this.buildClassification(ordered);
1004
- }
1005
-
1006
- buildClassification(ordered = this.orderedCars()) {
1007
- const leaderDistance = ordered[0]?.raceDistance ?? 0;
1008
- return ordered.map((car, index) => ({
1009
- id: car.id,
1010
- code: car.code,
1011
- timingCode: car.timingCode,
1012
- name: car.name,
1013
- rank: index + 1,
1014
- raceDistance: car.raceDistance,
1015
- distanceMeters: simUnitsToMeters(car.raceDistance),
1016
- lap: this.computeLap(car.raceDistance),
1017
- lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
1018
- gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
1019
- gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
1020
- intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
1021
- finished: car.raceDistance >= this.finishDistance,
1022
- finishTime: car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null),
1023
- }));
463
+ return buildClassificationFromFinishOrderState(this);
1024
464
  }
1025
465
 
1026
- getRaceWinnerSnapshot() {
1027
- if (!this.raceControl.winnerId) return null;
1028
- const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
1029
- if (!car) return null;
1030
- const rank = car.classifiedRank ?? car.rank ?? 1;
1031
- return serializeCar(car, rank);
1032
- }
1033
-
1034
- updateDrsLatch(car, ahead, orderIndex) {
1035
- if (this.safetyCar.deployed || car.finished) {
1036
- car.drsEligible = false;
1037
- car.drsActive = false;
1038
- car.drsZoneId = null;
1039
- car.drsZoneEnabled = false;
1040
- return;
1041
- }
466
+ applyClassificationConsequences(ordered, penaltyStats = this.getPenaltyStatsByDriver()) {
467
+ return applyClassificationConsequencesState(this, ordered, penaltyStats);
468
+ }
1042
469
 
1043
- const previousProgress = car.previousProgress ?? car.progress;
1044
- const currentZone = car.drsZoneId
1045
- ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
1046
- : null;
470
+ buildClassification(ordered = this.orderedCars(), penaltyStats = this.getPenaltyStatsByDriver()) {
471
+ return buildClassificationState(this, ordered, penaltyStats);
472
+ }
1047
473
 
1048
- if (currentZone && !isProgressInZone(this.track, car.progress, currentZone)) {
1049
- car.drsZoneId = null;
1050
- car.drsZoneEnabled = false;
1051
- }
474
+ getRaceWinnerSnapshot() {
475
+ return getRaceWinnerSnapshotForSimulation(this);
476
+ }
1052
477
 
1053
- if (!car.drsZoneId) {
1054
- const crossedZone = this.track.drsZones.find((zone) => (
1055
- crossesDistance(previousProgress, car.progress, zone.start, this.track.length)
1056
- ));
1057
- if (crossedZone) {
1058
- car.drsZoneId = crossedZone.id;
1059
- const crossing = recordDrsDetection(car, crossedZone.id, this.time);
1060
- const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
1061
- car.drsZoneEnabled = Boolean(
1062
- orderIndex > 0 &&
1063
- aheadCrossing &&
1064
- aheadCrossing.passage === crossing.passage &&
1065
- crossing.time >= aheadCrossing.time &&
1066
- crossing.time - aheadCrossing.time <= this.rules.drsDetectionSeconds + 1e-6
1067
- );
1068
- }
1069
- }
478
+ getDrsReferenceCar(car) {
479
+ return getDrsReferenceCarForSimulation(this, car);
480
+ }
1070
481
 
1071
- const activeZone = car.drsZoneId
1072
- ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
1073
- : null;
1074
- const inLatchedZone = activeZone ? isProgressInZone(this.track, car.progress, activeZone) : false;
1075
- car.drsEligible = Boolean(car.drsZoneEnabled && inLatchedZone);
1076
- car.drsActive = car.drsEligible;
482
+ updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
483
+ updateDrsLatchState(car, ahead, {
484
+ hasReference,
485
+ safetyCarDeployed: this.safetyCar.deployed,
486
+ time: this.time,
487
+ track: this.track,
488
+ rules: this.rules,
489
+ });
1077
490
  }
1078
491
 
1079
492
  resolveCollisions() {
1080
- const reportedContacts = new Set();
1081
-
1082
- for (let pass = 0; pass < 3; pass += 1) {
1083
- for (let i = 0; i < this.cars.length; i += 1) {
1084
- for (let j = i + 1; j < this.cars.length; j += 1) {
1085
- const first = this.cars[i];
1086
- const second = this.cars[j];
1087
- const collision = detectObbCollision(first, second) ?? detectLongitudinalCollision(first, second);
1088
- if (!collision) continue;
1089
-
1090
- const correction = Math.min(
1091
- collision.depth / 2 + (collision.longitudinal ? 0.35 : 0.65),
1092
- MAX_COLLISION_CORRECTION,
1093
- );
1094
- first.x -= collision.axis.x * correction;
1095
- first.y -= collision.axis.y * correction;
1096
- second.x += collision.axis.x * correction;
1097
- second.y += collision.axis.y * correction;
1098
-
1099
- this.applyContactVelocityResponse(first, second, collision.axis);
1100
-
1101
- const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
1102
- const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
1103
- first.heading = normalizeAngle(first.heading - collision.axis.y * yawNudge);
1104
- second.heading = normalizeAngle(second.heading + collision.axis.y * yawNudge);
1105
- first.contactCooldown = 1;
1106
- second.contactCooldown = 1;
1107
-
1108
- const contactKey = `${first.id}:${second.id}`;
1109
- if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
1110
- reportedContacts.add(contactKey);
1111
- this.events.unshift({ type: 'contact', at: this.time, carId: first.id, otherCarId: second.id });
1112
- }
1113
- }
1114
- }
1115
- }
493
+ resolveCollisionsForSimulation(this);
1116
494
  }
1117
495
 
1118
496
  applyContactVelocityResponse(first, second, axis) {
1119
- const firstForward = forwardVector(first);
1120
- const secondForward = forwardVector(second);
1121
- const firstNormal = dot(firstForward, axis);
1122
- const secondNormal = dot(secondForward, axis);
1123
- const relativeNormalVelocity = second.speed * secondNormal - first.speed * firstNormal;
1124
-
1125
- if (relativeNormalVelocity < 0) {
1126
- const impulse = clamp(-relativeNormalVelocity * (0.34 + this.rules.collisionRestitution), 0, 16);
1127
- if (firstNormal > 0) first.speed = clamp(first.speed - impulse * firstNormal, 0, VEHICLE_LIMITS.maxSpeed);
1128
- if (secondNormal < 0) second.speed = clamp(second.speed + impulse * secondNormal, 0, VEHICLE_LIMITS.maxSpeed);
1129
- }
1130
-
1131
- first.speed = clamp(first.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
1132
- second.speed = clamp(second.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
497
+ applyContactVelocityResponseForSimulation(this, first, second, axis);
1133
498
  }
1134
499
 
1135
500
  computeLap(raceDistance) {
1136
- return clamp(Math.floor(Math.max(0, raceDistance) / this.track.length) + 1, 1, this.totalLaps);
501
+ return computeLapForDistance(raceDistance, this.track.length, this.totalLaps);
1137
502
  }
1138
503
 
1139
504
  get finishDistance() {
1140
- return this.track.length * this.totalLaps;
505
+ return finishDistanceForRace(this.track.length, this.totalLaps);
1141
506
  }
1142
507
  }
1143
508
 
1144
509
  export function createRaceSimulation(options = {}) {
1145
- return new F1RaceSimulation(options);
510
+ const resolvedOptions = withWarmupSurfaceOptions(options, options?.warmup?.surface ?? 'simulation');
511
+ runWarmupWithGuard({
512
+ options: resolvedOptions,
513
+ surface: resolvedOptions.warmup.surface,
514
+ execute: ({ warmup }) => {
515
+ const warmupSimulation = new F1RaceSimulation(disableWarmupOptions(resolvedOptions, warmup.surface));
516
+ for (let index = 0; index < warmup.steps; index += 1) warmupSimulation.step(FIXED_STEP);
517
+ },
518
+ });
519
+ return new F1RaceSimulation(resolvedOptions);
1146
520
  }