@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
@@ -0,0 +1,128 @@
1
+ import { clamp, lerp, normalizeAngle } from '../simMath.js';
2
+
3
+ const DEFAULT_GHOST_COLOR = '#00ff84';
4
+ const DEFAULT_GHOST_OPACITY = 0.35;
5
+
6
+ export function normalizeReplayGhosts(replayGhosts = []) {
7
+ if (!Array.isArray(replayGhosts)) return [];
8
+ return replayGhosts
9
+ .map((ghost, index) => normalizeReplayGhost(ghost, index))
10
+ .filter(Boolean);
11
+ }
12
+
13
+ export function updateReplayGhosts(replayGhosts = [], timeSeconds = 0) {
14
+ replayGhosts.forEach((ghost) => {
15
+ const previous = {
16
+ x: ghost.x,
17
+ y: ghost.y,
18
+ heading: ghost.heading,
19
+ };
20
+ const sample = interpolateTrajectory(ghost.trajectory, timeSeconds);
21
+ Object.assign(ghost, {
22
+ previousX: Number.isFinite(previous.x) ? previous.x : sample.x,
23
+ previousY: Number.isFinite(previous.y) ? previous.y : sample.y,
24
+ previousHeading: Number.isFinite(previous.heading) ? previous.heading : sample.heading,
25
+ x: sample.x,
26
+ y: sample.y,
27
+ heading: sample.heading,
28
+ speedKph: sample.speedKph,
29
+ progressMeters: sample.progressMeters,
30
+ timeSeconds,
31
+ });
32
+ });
33
+ }
34
+
35
+ export function serializeReplayGhosts(replayGhosts = []) {
36
+ return replayGhosts.map((ghost) => ({
37
+ id: ghost.id,
38
+ label: ghost.label,
39
+ color: ghost.color,
40
+ opacity: ghost.opacity,
41
+ visible: ghost.visible,
42
+ previousX: ghost.previousX,
43
+ previousY: ghost.previousY,
44
+ x: ghost.x,
45
+ y: ghost.y,
46
+ previousHeading: ghost.previousHeading,
47
+ heading: ghost.heading,
48
+ speedKph: ghost.speedKph,
49
+ progressMeters: ghost.progressMeters,
50
+ timeSeconds: ghost.timeSeconds,
51
+ sensors: { ...ghost.sensors },
52
+ }));
53
+ }
54
+
55
+ function normalizeReplayGhost(ghost, index) {
56
+ if (!ghost || typeof ghost !== 'object') return null;
57
+ const trajectory = normalizeTrajectory(ghost.trajectory);
58
+ if (trajectory.length === 0) return null;
59
+ const id = String(ghost.id ?? `replay-ghost-${index + 1}`);
60
+ const initial = interpolateTrajectory(trajectory, 0);
61
+ return {
62
+ id,
63
+ label: String(ghost.label ?? id),
64
+ color: typeof ghost.color === 'string' && ghost.color ? ghost.color : DEFAULT_GHOST_COLOR,
65
+ opacity: clamp(Number(ghost.opacity ?? DEFAULT_GHOST_OPACITY), 0, 1),
66
+ visible: ghost.visible !== false,
67
+ trajectory,
68
+ sensors: {
69
+ detectableByRays: Boolean(ghost.sensors?.detectableByRays),
70
+ detectableAsNearby: Boolean(ghost.sensors?.detectableAsNearby),
71
+ },
72
+ previousX: initial.x,
73
+ previousY: initial.y,
74
+ x: initial.x,
75
+ y: initial.y,
76
+ previousHeading: initial.heading,
77
+ heading: initial.heading,
78
+ speedKph: initial.speedKph,
79
+ progressMeters: initial.progressMeters,
80
+ timeSeconds: 0,
81
+ };
82
+ }
83
+
84
+ function normalizeTrajectory(trajectory) {
85
+ if (!Array.isArray(trajectory)) return [];
86
+ return trajectory
87
+ .map((sample) => {
88
+ const timeSeconds = finiteNumber(sample?.timeSeconds);
89
+ const x = finiteNumber(sample?.x);
90
+ const y = finiteNumber(sample?.y);
91
+ const heading = finiteNumber(sample?.headingRadians ?? sample?.heading);
92
+ if (timeSeconds == null || x == null || y == null || heading == null) return null;
93
+ return {
94
+ timeSeconds,
95
+ x,
96
+ y,
97
+ heading,
98
+ speedKph: finiteNumber(sample?.speedKph) ?? 0,
99
+ progressMeters: finiteNumber(sample?.progressMeters) ?? 0,
100
+ };
101
+ })
102
+ .filter(Boolean)
103
+ .sort((a, b) => a.timeSeconds - b.timeSeconds);
104
+ }
105
+
106
+ function interpolateTrajectory(trajectory, timeSeconds) {
107
+ if (trajectory.length === 1 || timeSeconds <= trajectory[0].timeSeconds) return { ...trajectory[0] };
108
+ const last = trajectory[trajectory.length - 1];
109
+ if (timeSeconds >= last.timeSeconds) return { ...last };
110
+ const nextIndex = trajectory.findIndex((sample) => sample.timeSeconds >= timeSeconds);
111
+ const previous = trajectory[Math.max(0, nextIndex - 1)];
112
+ const next = trajectory[nextIndex];
113
+ const span = next.timeSeconds - previous.timeSeconds || 1;
114
+ const amount = clamp((timeSeconds - previous.timeSeconds) / span, 0, 1);
115
+ return {
116
+ timeSeconds,
117
+ x: lerp(previous.x, next.x, amount),
118
+ y: lerp(previous.y, next.y, amount),
119
+ heading: previous.heading + normalizeAngle(next.heading - previous.heading) * amount,
120
+ speedKph: lerp(previous.speedKph, next.speedKph, amount),
121
+ progressMeters: lerp(previous.progressMeters, next.progressMeters, amount),
122
+ };
123
+ }
124
+
125
+ function finiteNumber(value) {
126
+ const number = Number(value);
127
+ return Number.isFinite(number) ? number : null;
128
+ }
@@ -0,0 +1,69 @@
1
+ import { simSpeedToKph } from '../units.js';
2
+
3
+ export function calculateCollisionPenalties({ first, second, collision = null, rule }) {
4
+ if (!rule) return [];
5
+ const severity = collision?.depth ?? 0;
6
+ const severityThreshold = (rule.minimumSeverity ?? 0) + (rule.relaxedSeverityMargin ?? 0) * (1 - rule.strictness);
7
+ const impactSpeed = collision?.impactSpeed ?? 0;
8
+ const impactSpeedThreshold = (rule.minimumImpactSpeed ?? 0) + (rule.relaxedImpactSpeed ?? 0) * (1 - rule.strictness);
9
+ const atFaultDriverId = collision?.atFaultDriverId ?? null;
10
+ const aheadDriverId = collision?.aheadDriverId ?? null;
11
+ const sharedFaultDriverIds = Array.isArray(collision?.sharedFaultDriverIds)
12
+ ? collision.sharedFaultDriverIds
13
+ : [];
14
+
15
+ if (severity < severityThreshold) return [];
16
+ if (impactSpeed < impactSpeedThreshold) return [];
17
+
18
+ if (sharedFaultDriverIds.length > 0) {
19
+ return sharedFaultDriverIds
20
+ .map((driverId) => {
21
+ const car = first.id === driverId ? first : second.id === driverId ? second : null;
22
+ if (!car) return null;
23
+ const other = first.id === driverId ? second : first;
24
+ return buildCollisionPenalty(car, other, rule, {
25
+ severity,
26
+ severityThreshold,
27
+ impactSpeed,
28
+ impactSpeedThreshold,
29
+ aheadDriverId: null,
30
+ atFaultDriverId: driverId,
31
+ sharedFault: true,
32
+ reason: 'Unclear collision responsibility',
33
+ });
34
+ })
35
+ .filter(Boolean);
36
+ }
37
+
38
+ if (!atFaultDriverId || !aheadDriverId) return [];
39
+
40
+ const atFault = first.id === atFaultDriverId ? first : second;
41
+ const other = first.id === atFaultDriverId ? second : first;
42
+ return [buildCollisionPenalty(atFault, other, rule, {
43
+ severity,
44
+ severityThreshold,
45
+ impactSpeed,
46
+ impactSpeedThreshold,
47
+ aheadDriverId,
48
+ atFaultDriverId,
49
+ })];
50
+ }
51
+
52
+ function buildCollisionPenalty(car, other, rule, context) {
53
+ return {
54
+ type: 'collision',
55
+ driverId: car.id,
56
+ otherCarId: other.id,
57
+ aheadDriverId: context.aheadDriverId,
58
+ atFaultDriverId: context.atFaultDriverId,
59
+ strictness: rule.strictness,
60
+ penaltySeconds: rule.timePenaltySeconds,
61
+ consequences: rule.consequences,
62
+ severity: context.severity,
63
+ threshold: context.severityThreshold,
64
+ impactSpeedKph: simSpeedToKph(context.impactSpeed),
65
+ impactSpeedThresholdKph: simSpeedToKph(context.impactSpeedThreshold),
66
+ sharedFault: Boolean(context.sharedFault),
67
+ reason: context.reason ?? 'Avoidable contact with car ahead',
68
+ };
69
+ }
@@ -0,0 +1,72 @@
1
+ import { metersToSimUnits } from '../units.js';
2
+ import { DEFAULT_MODULES } from './ruleDefaults.js';
3
+ import { isPlainObject, clamp01, mergeConfig, nonNegativeInteger, positiveInteger, positiveNumber } from './ruleConfigMerge.js';
4
+ import { normalizePenaltyConfig } from './penaltyConfig.js';
5
+
6
+ const MODULE_NAMES = Object.keys(DEFAULT_MODULES);
7
+
8
+ export function normalizeModules(modules, explicitModules = {}) {
9
+ const next = mergeConfig(DEFAULT_MODULES, modules);
10
+ const explicitPitSpeed = isPlainObject(explicitModules?.penalties?.pitLaneSpeeding) &&
11
+ Object.hasOwn(explicitModules.penalties.pitLaneSpeeding, 'speedLimitKph');
12
+
13
+ MODULE_NAMES.forEach((name) => {
14
+ next[name] = {
15
+ ...next[name],
16
+ enabled: Boolean(next[name].enabled),
17
+ };
18
+ });
19
+
20
+ next.pitStops.pitLaneSpeedLimitKph = positiveNumber(next.pitStops.pitLaneSpeedLimitKph, 80);
21
+ next.pitStops.defaultStopSeconds = positiveNumber(next.pitStops.defaultStopSeconds, 2.8);
22
+ next.pitStops.variability = {
23
+ ...DEFAULT_MODULES.pitStops.variability,
24
+ ...(isPlainObject(next.pitStops.variability) ? next.pitStops.variability : {}),
25
+ };
26
+ next.pitStops.variability.enabled = Boolean(next.pitStops.variability.enabled);
27
+ next.pitStops.variability.perfect = Boolean(next.pitStops.variability.perfect);
28
+ next.pitStops.variability.speedImpactSeconds = Math.max(
29
+ 0,
30
+ Number(next.pitStops.variability.speedImpactSeconds) || DEFAULT_MODULES.pitStops.variability.speedImpactSeconds,
31
+ );
32
+ next.pitStops.variability.consistencyJitterSeconds = Math.max(
33
+ 0,
34
+ Number(next.pitStops.variability.consistencyJitterSeconds) || DEFAULT_MODULES.pitStops.variability.consistencyJitterSeconds,
35
+ );
36
+ next.pitStops.variability.issueChance = clamp01(
37
+ next.pitStops.variability.issueChance,
38
+ DEFAULT_MODULES.pitStops.variability.issueChance,
39
+ );
40
+ next.pitStops.variability.issueMaxDelaySeconds = Math.max(
41
+ 0,
42
+ Number(next.pitStops.variability.issueMaxDelaySeconds) || DEFAULT_MODULES.pitStops.variability.issueMaxDelaySeconds,
43
+ );
44
+ next.pitStops.maxConcurrentPitLaneCars = positiveInteger(next.pitStops.maxConcurrentPitLaneCars, 3);
45
+ next.pitStops.minimumPitLaneGapMeters = positiveNumber(next.pitStops.minimumPitLaneGapMeters, 20);
46
+ next.pitStops.minimumPitLaneGap = metersToSimUnits(next.pitStops.minimumPitLaneGapMeters);
47
+ next.pitStops.doubleStacking = Boolean(next.pitStops.doubleStacking);
48
+ next.pitStops.tirePitRequestThresholdPercent = clamp01(
49
+ Number(next.pitStops.tirePitRequestThresholdPercent) / 100,
50
+ 0.5,
51
+ ) * 100;
52
+ next.pitStops.tirePitCommitThresholdPercent = Math.min(
53
+ next.pitStops.tirePitRequestThresholdPercent,
54
+ clamp01(Number(next.pitStops.tirePitCommitThresholdPercent) / 100, 0.3) * 100,
55
+ );
56
+
57
+ next.tireStrategy.compounds = Array.isArray(next.tireStrategy.compounds) && next.tireStrategy.compounds.length
58
+ ? [...next.tireStrategy.compounds]
59
+ : ['S', 'M', 'H'];
60
+ next.tireStrategy.mandatoryDistinctDryCompounds = next.tireStrategy.mandatoryDistinctDryCompounds == null
61
+ ? null
62
+ : nonNegativeInteger(next.tireStrategy.mandatoryDistinctDryCompounds, 0);
63
+
64
+ next.stalledDnf.maxStoppedSeconds = positiveNumber(next.stalledDnf.maxStoppedSeconds, 12);
65
+ next.stalledDnf.speedThresholdKph = positiveNumber(next.stalledDnf.speedThresholdKph, 5);
66
+
67
+ next.penalties = normalizePenaltyConfig(next.penalties);
68
+ if (!explicitPitSpeed) {
69
+ next.penalties.pitLaneSpeeding.speedLimitKph = next.pitStops.pitLaneSpeedLimitKph;
70
+ }
71
+ return next;
72
+ }
@@ -0,0 +1,99 @@
1
+ import { metersToSimUnits } from '../units.js';
2
+ import { clamp01, nonNegativeInteger, positiveNumber } from './ruleConfigMerge.js';
3
+
4
+ export const PENALTY_SUBSECTIONS = ['trackLimits', 'collision', 'tireRequirement', 'pitLaneSpeeding'];
5
+
6
+ const CONSEQUENCE_TYPES = new Set([
7
+ 'warning',
8
+ 'time',
9
+ 'driveThrough',
10
+ 'stopGo',
11
+ 'positionDrop',
12
+ 'gridDrop',
13
+ 'disqualification',
14
+ ]);
15
+
16
+ export function normalizePenaltyConfig(penalties) {
17
+ const next = {
18
+ ...penalties,
19
+ enabled: Boolean(penalties.enabled),
20
+ stewardStrictness: clamp01(penalties.stewardStrictness, 1),
21
+ };
22
+
23
+ PENALTY_SUBSECTIONS.forEach((name) => {
24
+ next[name] = {
25
+ ...penalties[name],
26
+ strictness: clamp01(penalties[name]?.strictness, 0),
27
+ };
28
+ });
29
+
30
+ next.trackLimits.warningsBeforePenalty = nonNegativeInteger(next.trackLimits.warningsBeforePenalty, 3);
31
+ next.trackLimits.consequences = normalizeConsequences(next.trackLimits.consequences, positiveNumber(next.trackLimits.timePenaltySeconds, 5));
32
+ next.trackLimits.timePenaltySeconds = sumTimeConsequences(next.trackLimits.consequences);
33
+ next.trackLimits.relaxedMarginMeters = Math.max(0, Number(next.trackLimits.relaxedMarginMeters) || 0);
34
+ next.trackLimits.relaxedMargin = metersToSimUnits(next.trackLimits.relaxedMarginMeters);
35
+
36
+ next.collision.consequences = normalizeConsequences(next.collision.consequences, positiveNumber(next.collision.timePenaltySeconds, 5));
37
+ next.collision.timePenaltySeconds = sumTimeConsequences(next.collision.consequences);
38
+ next.collision.minimumSeverity = Math.max(0, Number(next.collision.minimumSeverity) || 0);
39
+ next.collision.relaxedSeverityMargin = Math.max(0, Number(next.collision.relaxedSeverityMargin) || 0);
40
+ next.collision.minimumImpactSpeedKph = Math.max(0, Number(next.collision.minimumImpactSpeedKph) || 0);
41
+ next.collision.relaxedImpactSpeedKph = Math.max(0, Number(next.collision.relaxedImpactSpeedKph) || 0);
42
+ next.collision.minimumImpactSpeed = metersToSimUnits(next.collision.minimumImpactSpeedKph / 3.6);
43
+ next.collision.relaxedImpactSpeed = metersToSimUnits(next.collision.relaxedImpactSpeedKph / 3.6);
44
+ next.tireRequirement.consequences = normalizeConsequences(next.tireRequirement.consequences, positiveNumber(next.tireRequirement.timePenaltySeconds, 10));
45
+ next.tireRequirement.timePenaltySeconds = sumTimeConsequences(next.tireRequirement.consequences);
46
+ next.pitLaneSpeeding.speedLimitKph = positiveNumber(next.pitLaneSpeeding.speedLimitKph, 80);
47
+ next.pitLaneSpeeding.marginKph = Math.max(0, Number(next.pitLaneSpeeding.marginKph) || 0);
48
+ next.pitLaneSpeeding.relaxedMarginKph = Math.max(0, Number(next.pitLaneSpeeding.relaxedMarginKph) || 0);
49
+ next.pitLaneSpeeding.consequences = normalizeConsequences(next.pitLaneSpeeding.consequences, positiveNumber(next.pitLaneSpeeding.timePenaltySeconds, 5));
50
+ next.pitLaneSpeeding.timePenaltySeconds = sumTimeConsequences(next.pitLaneSpeeding.consequences);
51
+
52
+ next.enabled = next.enabled || PENALTY_SUBSECTIONS.some((name) => next[name].strictness > 0);
53
+ return next;
54
+ }
55
+
56
+ function normalizeConsequences(value, fallbackSeconds) {
57
+ const source = Array.isArray(value) && value.length
58
+ ? value
59
+ : [{ type: 'time', seconds: fallbackSeconds }];
60
+ const normalized = source
61
+ .map((consequence) => {
62
+ const type = CONSEQUENCE_TYPES.has(consequence?.type) ? consequence.type : null;
63
+ if (!type) return null;
64
+ if (type === 'time') {
65
+ return {
66
+ type,
67
+ seconds: positiveNumber(consequence.seconds, fallbackSeconds),
68
+ };
69
+ }
70
+ if (type === 'driveThrough') {
71
+ return {
72
+ type,
73
+ conversionSeconds: positiveNumber(consequence.conversionSeconds, 20),
74
+ };
75
+ }
76
+ if (type === 'stopGo') {
77
+ return {
78
+ type,
79
+ seconds: positiveNumber(consequence.seconds, 10),
80
+ conversionSeconds: positiveNumber(consequence.conversionSeconds, 30),
81
+ };
82
+ }
83
+ if (type === 'positionDrop' || type === 'gridDrop') {
84
+ return {
85
+ type,
86
+ positions: nonNegativeInteger(consequence.positions, 1),
87
+ };
88
+ }
89
+ return { type };
90
+ })
91
+ .filter(Boolean);
92
+ return normalized.length ? normalized : [{ type: 'time', seconds: fallbackSeconds }];
93
+ }
94
+
95
+ function sumTimeConsequences(consequences) {
96
+ return consequences.reduce((total, consequence) => (
97
+ consequence.type === 'time' ? total + consequence.seconds : total
98
+ ), 0);
99
+ }
@@ -0,0 +1,18 @@
1
+ export function createPenaltyServedEvent(entry, time) {
2
+ return {
3
+ type: 'penalty-served',
4
+ at: time,
5
+ penaltyId: entry.id,
6
+ driverId: entry.driverId,
7
+ serviceType: entry.serviceType,
8
+ };
9
+ }
10
+
11
+ export function createPenaltyCancelledEvent(entry, time) {
12
+ return {
13
+ type: 'penalty-cancelled',
14
+ at: time,
15
+ penaltyId: entry.id,
16
+ driverId: entry.driverId,
17
+ };
18
+ }
@@ -0,0 +1,153 @@
1
+ const DEFAULT_SERVICE_CONVERSION_SECONDS = {
2
+ driveThrough: 20,
3
+ stopGo: 30,
4
+ };
5
+
6
+ export function createPenaltyRecord({ sequence, time, lap, penalty }) {
7
+ const consequences = normalizePenaltyConsequences(penalty.consequences, penalty.penaltySeconds);
8
+ const immediatePenaltySeconds = sumTimeConsequences(consequences);
9
+ const serviceConsequence = consequences.find((consequence) => (
10
+ consequence.type === 'driveThrough' || consequence.type === 'stopGo'
11
+ ));
12
+ const status = penalty.status ?? (serviceConsequence ? 'issued' : 'applied');
13
+
14
+ return {
15
+ id: `penalty-${sequence}`,
16
+ at: time,
17
+ lap,
18
+ ...penalty,
19
+ status,
20
+ consequences,
21
+ penaltySeconds: Number.isFinite(Number(penalty.penaltySeconds))
22
+ ? Number(penalty.penaltySeconds)
23
+ : immediatePenaltySeconds,
24
+ pendingPenaltySeconds: serviceConsequence
25
+ ? serviceConversionSeconds(serviceConsequence)
26
+ : 0,
27
+ serviceType: serviceConsequence?.type ?? null,
28
+ serviceRequired: Boolean(serviceConsequence),
29
+ serviceServedAt: null,
30
+ unserved: false,
31
+ positionDrop: sumPositionDropConsequences(consequences),
32
+ gridDrop: sumGridDropConsequences(consequences),
33
+ disqualified: consequences.some((consequence) => consequence.type === 'disqualification'),
34
+ };
35
+ }
36
+
37
+ export function createPenaltyEvent(entry) {
38
+ return {
39
+ type: 'penalty',
40
+ at: entry.at,
41
+ penaltyId: entry.id,
42
+ penaltyType: entry.type,
43
+ driverId: entry.driverId,
44
+ penaltySeconds: entry.penaltySeconds,
45
+ status: entry.status,
46
+ strictness: entry.strictness,
47
+ };
48
+ }
49
+
50
+ export function serializePenalty(penalty) {
51
+ return { ...penalty };
52
+ }
53
+
54
+ export function servePenaltyRecord(penalty, time) {
55
+ if (!penalty || penalty.status === 'cancelled') return penalty;
56
+ if (!penalty.serviceRequired) return penalty;
57
+ penalty.status = 'served';
58
+ penalty.serviceServedAt = time;
59
+ penalty.unserved = false;
60
+ return penalty;
61
+ }
62
+
63
+ export function getPitServicePenaltySeconds(penalty) {
64
+ if (!penalty || penalty.status === 'cancelled' || penalty.status === 'served') return 0;
65
+ if (penalty.serviceRequired && penalty.status === 'issued') {
66
+ if (penalty.serviceType === 'stopGo') return sumStopGoServiceSeconds(penalty.consequences);
67
+ if (penalty.serviceType === 'driveThrough') return 0;
68
+ }
69
+ if (!penalty.serviceRequired && penalty.status === 'applied') {
70
+ return sumTimeConsequences(penalty.consequences);
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ export function isPenaltyPitServiceable(penalty) {
76
+ if (!penalty || penalty.status === 'cancelled' || penalty.status === 'served') return false;
77
+ if (penalty.serviceRequired && penalty.status === 'issued') return true;
78
+ return !penalty.serviceRequired &&
79
+ penalty.status === 'applied' &&
80
+ getPitServicePenaltySeconds(penalty) > 0;
81
+ }
82
+
83
+ export function servePitPenaltyRecord(penalty, time) {
84
+ if (!isPenaltyPitServiceable(penalty)) return penalty;
85
+ penalty.status = 'served';
86
+ penalty.serviceServedAt = time;
87
+ penalty.unserved = false;
88
+ penalty.penaltySeconds = 0;
89
+ penalty.pendingPenaltySeconds = 0;
90
+ return penalty;
91
+ }
92
+
93
+ export function cancelPenaltyRecord(penalty, time) {
94
+ if (!penalty) return penalty;
95
+ penalty.status = 'cancelled';
96
+ penalty.cancelledAt = time;
97
+ penalty.penaltySeconds = 0;
98
+ penalty.pendingPenaltySeconds = 0;
99
+ penalty.positionDrop = 0;
100
+ penalty.gridDrop = 0;
101
+ penalty.disqualified = false;
102
+ penalty.unserved = false;
103
+ return penalty;
104
+ }
105
+
106
+ export function applyUnservedServicePenalty(penalty, time) {
107
+ if (!penalty || penalty.status !== 'issued' || !penalty.serviceRequired) return penalty;
108
+ penalty.status = 'applied';
109
+ penalty.appliedAt = time;
110
+ penalty.unserved = true;
111
+ penalty.penaltySeconds += penalty.pendingPenaltySeconds;
112
+ return penalty;
113
+ }
114
+
115
+ export function isPenaltyActive(penalty) {
116
+ return penalty?.status !== 'cancelled';
117
+ }
118
+
119
+ function normalizePenaltyConsequences(consequences, fallbackSeconds = 0) {
120
+ if (Array.isArray(consequences) && consequences.length) return consequences.map((consequence) => ({ ...consequence }));
121
+ const seconds = Number(fallbackSeconds) || 0;
122
+ return seconds > 0 ? [{ type: 'time', seconds }] : [];
123
+ }
124
+
125
+ function sumTimeConsequences(consequences) {
126
+ return consequences.reduce((total, consequence) => (
127
+ consequence.type === 'time' ? total + (Number(consequence.seconds) || 0) : total
128
+ ), 0);
129
+ }
130
+
131
+ function sumStopGoServiceSeconds(consequences) {
132
+ return consequences.reduce((total, consequence) => (
133
+ consequence.type === 'stopGo' ? total + (Number(consequence.seconds) || 0) : total
134
+ ), 0);
135
+ }
136
+
137
+ function sumPositionDropConsequences(consequences) {
138
+ return consequences.reduce((total, consequence) => (
139
+ consequence.type === 'positionDrop' ? total + Math.max(0, Math.floor(Number(consequence.positions) || 0)) : total
140
+ ), 0);
141
+ }
142
+
143
+ function sumGridDropConsequences(consequences) {
144
+ return consequences.reduce((total, consequence) => (
145
+ consequence.type === 'gridDrop' ? total + Math.max(0, Math.floor(Number(consequence.positions) || 0)) : total
146
+ ), 0);
147
+ }
148
+
149
+ function serviceConversionSeconds(consequence) {
150
+ return Number.isFinite(Number(consequence.conversionSeconds))
151
+ ? Math.max(0, Number(consequence.conversionSeconds))
152
+ : DEFAULT_SERVICE_CONVERSION_SECONDS[consequence.type] ?? 0;
153
+ }
@@ -0,0 +1,26 @@
1
+ import { isPenaltyActive } from './penaltyLedger.js';
2
+
3
+ export function createEmptyPenaltyStats() {
4
+ return {
5
+ seconds: 0,
6
+ positionDrop: 0,
7
+ disqualified: false,
8
+ };
9
+ }
10
+
11
+ export function getPenaltyStats(statsByDriver, driverId) {
12
+ return statsByDriver.get(driverId) ?? createEmptyPenaltyStats();
13
+ }
14
+
15
+ export function buildPenaltyStatsByDriver(penalties = []) {
16
+ const byDriver = new Map();
17
+ penalties.forEach((penalty) => {
18
+ if (!penalty?.driverId || !isPenaltyActive(penalty)) return;
19
+ const stats = byDriver.get(penalty.driverId) ?? createEmptyPenaltyStats();
20
+ stats.seconds += Number(penalty.penaltySeconds) || 0;
21
+ stats.positionDrop += Number(penalty.positionDrop) || 0;
22
+ stats.disqualified = stats.disqualified || Boolean(penalty.disqualified);
23
+ byDriver.set(penalty.driverId, stats);
24
+ });
25
+ return byDriver;
26
+ }
@@ -0,0 +1,71 @@
1
+ const SPEED_LIMITED_PIT_PARTS = new Set(['fast-lane', 'working-lane', 'service-box', 'garage-box']);
2
+
3
+ export function calculatePitLaneSpeedingReview({ car, rule, stewardState }) {
4
+ const current = stewardState ?? { active: false, violations: 0 };
5
+ if (!rule) {
6
+ return {
7
+ nextState: current,
8
+ event: null,
9
+ penalty: null,
10
+ };
11
+ }
12
+
13
+ const speedKph = Number(car.speedKph);
14
+ const pitLanePart = car.trackState?.pitLanePart ?? car.pitLanePart;
15
+ const inLimitedPitLane = Boolean(car.trackState?.inPitLane) && SPEED_LIMITED_PIT_PARTS.has(pitLanePart);
16
+ const marginKph = (Number(rule.marginKph) || 0) + ((Number(rule.relaxedMarginKph) || 0) * (1 - rule.strictness));
17
+ const speedLimitKph = Number(rule.speedLimitKph) || 80;
18
+ const excessKph = speedKph - speedLimitKph;
19
+ const isViolation = inLimitedPitLane && excessKph > marginKph;
20
+
21
+ if (!isViolation) {
22
+ return {
23
+ nextState: { ...current, active: false },
24
+ event: null,
25
+ penalty: null,
26
+ };
27
+ }
28
+
29
+ if (current.active) {
30
+ return {
31
+ nextState: current,
32
+ event: null,
33
+ penalty: null,
34
+ };
35
+ }
36
+
37
+ const violationCount = current.violations + 1;
38
+ const nextState = {
39
+ active: true,
40
+ violations: violationCount,
41
+ };
42
+ const event = {
43
+ type: 'pit-lane-speeding',
44
+ carId: car.id,
45
+ violationCount,
46
+ speedKph,
47
+ speedLimitKph,
48
+ excessKph,
49
+ pitLanePart,
50
+ strictness: rule.strictness,
51
+ };
52
+
53
+ return {
54
+ nextState,
55
+ event,
56
+ penalty: {
57
+ type: 'pit-lane-speeding',
58
+ driverId: car.id,
59
+ strictness: rule.strictness,
60
+ penaltySeconds: rule.timePenaltySeconds,
61
+ consequences: rule.consequences,
62
+ violationCount,
63
+ speedKph,
64
+ speedLimitKph,
65
+ excessKph,
66
+ marginKph,
67
+ pitLanePart,
68
+ reason: 'Exceeded pit lane speed limit',
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,41 @@
1
+ export function isPlainObject(value) {
2
+ return value != null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+
5
+ export function clone(value) {
6
+ if (Array.isArray(value)) return value.map((item) => clone(item));
7
+ if (!isPlainObject(value)) return value;
8
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
9
+ }
10
+
11
+ export function mergeConfig(base, override) {
12
+ if (!isPlainObject(override)) return clone(base);
13
+ const next = clone(base);
14
+ Object.entries(override).forEach(([key, value]) => {
15
+ next[key] = isPlainObject(value) && isPlainObject(next[key])
16
+ ? mergeConfig(next[key], value)
17
+ : clone(value);
18
+ });
19
+ return next;
20
+ }
21
+
22
+ export function clamp01(value, fallback = 0) {
23
+ const numeric = Number(value);
24
+ if (!Number.isFinite(numeric)) return fallback;
25
+ return Math.max(0, Math.min(1, numeric));
26
+ }
27
+
28
+ export function positiveNumber(value, fallback) {
29
+ const numeric = Number(value);
30
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
31
+ }
32
+
33
+ export function nonNegativeInteger(value, fallback) {
34
+ const numeric = Number(value);
35
+ return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : fallback;
36
+ }
37
+
38
+ export function positiveInteger(value, fallback) {
39
+ const numeric = Number(value);
40
+ return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback;
41
+ }