@inventure71/paddockjs 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL_AND_UPDATE.md +3 -1
  3. package/README.md +142 -9
  4. package/docs/architecture.md +49 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +185 -27
  7. package/docs/data_model.md +1 -1
  8. package/docs/learnings.md +1 -1
  9. package/docs/rules.md +140 -19
  10. package/docs/system_specs.md +39 -16
  11. package/docs/training.md +103 -1
  12. package/examples/train-basic-policy.mjs +5 -6
  13. package/examples/trainingData.mjs +133 -0
  14. package/package.json +5 -2
  15. package/src/api/PaddockSimulatorController.js +32 -2
  16. package/src/app/F1SimulatorApp.js +1101 -128
  17. package/src/app/domBindings.js +10 -1
  18. package/src/config/defaultOptions.js +4 -4
  19. package/src/environment/actions.js +25 -2
  20. package/src/environment/evaluation.js +184 -0
  21. package/src/environment/index.d.ts +281 -1
  22. package/src/environment/index.js +13 -0
  23. package/src/environment/observationOptions.js +12 -0
  24. package/src/environment/observations.js +189 -17
  25. package/src/environment/options.js +7 -0
  26. package/src/environment/recorder.js +28 -0
  27. package/src/environment/runtime.js +41 -6
  28. package/src/environment/scenarios.js +220 -0
  29. package/src/environment/sensors.js +145 -35
  30. package/src/environment/specs.js +92 -8
  31. package/src/environment/workerProtocol.js +52 -0
  32. package/src/index.d.ts +366 -3
  33. package/src/index.js +21 -0
  34. package/src/rendering/proceduralTrackAsset.js +336 -23
  35. package/src/simulation/collisionGeometry.js +214 -0
  36. package/src/simulation/driverController.js +431 -65
  37. package/src/simulation/raceSimulation.js +2132 -171
  38. package/src/simulation/rules/collisionSteward.js +69 -0
  39. package/src/simulation/rules/penaltyLedger.js +153 -0
  40. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  41. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  42. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  43. package/src/simulation/rulesConfig.js +352 -0
  44. package/src/simulation/trackModel.js +1154 -76
  45. package/src/simulation/units.js +6 -4
  46. package/src/simulation/vehicleGeometry.js +247 -0
  47. package/src/simulation/vehiclePhysics.js +82 -50
  48. package/src/simulation/wheelSurface.js +338 -0
  49. package/src/styles.css +300 -42
  50. package/src/ui/bannerTemplates.js +69 -0
  51. package/src/ui/componentTemplates.js +36 -96
  52. package/src/ui/raceControlStatusBanner.js +39 -0
  53. package/src/ui/shellTemplate.js +1 -1
  54. package/src/ui/templateUtils.js +23 -0
@@ -1,8 +1,34 @@
1
1
  import { pointAt, offsetTrackPoint } from './trackModel.js';
2
2
  import { clamp, normalizeAngle, seededRange, TWO_PI } from './simMath.js';
3
+ import { kphToSimSpeed, metersToSimUnits, simSpeedToKph, simUnitsToMeters } from './units.js';
3
4
  import { VEHICLE_LIMITS } from './vehiclePhysics.js';
4
5
 
5
- const LANE_OFFSETS = [-78, -52, -26, 0, 26, 52, 78];
6
+ const LANE_OFFSETS = [-8, -5.6, -3.2, 0, 3.2, 5.6, 8].map(metersToSimUnits);
7
+ const EDGE_RECOVERY_MIN_LOOKAHEAD = metersToSimUnits(28);
8
+ const LOOKAHEAD_BASE_DISTANCE = metersToSimUnits(52);
9
+ const LOOKAHEAD_MAX_DISTANCE = metersToSimUnits(115);
10
+ const BRAKING_LOOKAHEAD_MAX_DISTANCE = metersToSimUnits(560);
11
+ const TRAFFIC_GAP_AHEAD = metersToSimUnits(58);
12
+ const TRAFFIC_SIDE_GAP = metersToSimUnits(4.1);
13
+ const TRAFFIC_SIDE_OVERLAP = metersToSimUnits(4.6);
14
+ const TRAFFIC_REAR_WINDOW = metersToSimUnits(42);
15
+ const OVERTAKE_GAP_BASE = metersToSimUnits(26);
16
+ const OVERTAKE_GAP_AGGRESSION = metersToSimUnits(28);
17
+ const OVERTAKE_LATERAL_MIN = metersToSimUnits(2.4);
18
+ const OVERTAKE_LATERAL_REWARD = metersToSimUnits(1.35);
19
+ const PASS_SIDE_BASE = metersToSimUnits(3.4);
20
+ const PASS_SIDE_AGGRESSION = metersToSimUnits(4.6);
21
+ const DEFEND_REAR_GAP = metersToSimUnits(46);
22
+ const DEFEND_MIN_OFFSET = metersToSimUnits(2.4);
23
+ const LANE_EDGE_CLEARANCE_TARGET = metersToSimUnits(0.75);
24
+ const REJOIN_LOOKAHEAD_BASE = metersToSimUnits(56);
25
+ const REJOIN_LOOKAHEAD_MAX = metersToSimUnits(105);
26
+ const SAFETY_CAR_LOOKAHEAD_MIN = metersToSimUnits(24);
27
+ const SAFETY_CAR_LOOKAHEAD_MAX = metersToSimUnits(48);
28
+ const CURVATURE_LOOKAHEAD_SAMPLES = 5;
29
+ const PREVIEW_CURVATURE_CAP = 0.00115;
30
+ const REJOIN_HOLD_FRAMES = 150;
31
+ const OVERTAKE_COMMIT_FRAMES = 150;
6
32
 
7
33
  function angleToPoint(car, target) {
8
34
  const angle = Math.atan2(target.y - car.y, target.x - car.x);
@@ -71,6 +97,12 @@ export function decideDriverControls({ car, orderIndex, race }) {
71
97
  }
72
98
 
73
99
  if (!car.trackState.onTrack) {
100
+ car.rejoinRecoveryFrames = REJOIN_HOLD_FRAMES;
101
+ car.attackCommitmentFrames = 0;
102
+ return decideRejoinControls(car, race);
103
+ }
104
+
105
+ if (shouldContinueRejoinRecovery(car, race)) {
74
106
  return decideRejoinControls(car, race);
75
107
  }
76
108
 
@@ -79,91 +111,314 @@ export function decideDriverControls({ car, orderIndex, race }) {
79
111
 
80
112
  function decideRacingControls(car, orderIndex, race) {
81
113
  const aggression = car.aggression ?? car.personality?.baseAggression ?? 0.5;
82
- const lookahead = clamp(car.speed * (1.12 - aggression * 0.08) + 160, 160, 360);
114
+ const edgeGuard = calculateTrackEdgeGuard(car, race);
115
+ const baseLookahead = clamp(
116
+ car.speed * (1.62 - aggression * 0.12) + LOOKAHEAD_BASE_DISTANCE - edgeGuard.pressure * metersToSimUnits(18),
117
+ EDGE_RECOVERY_MIN_LOOKAHEAD,
118
+ LOOKAHEAD_MAX_DISTANCE,
119
+ );
120
+ const previewLookahead = clamp(
121
+ car.speed * 3.15 + LOOKAHEAD_BASE_DISTANCE,
122
+ baseLookahead,
123
+ BRAKING_LOOKAHEAD_MAX_DISTANCE,
124
+ );
125
+ const previewCurvature = maxLookaheadCurvature(race.track, car.progress, previewLookahead);
126
+ const cornerPressure = clamp(previewCurvature / 0.001, 0, 1);
127
+ const lookahead = clamp(
128
+ baseLookahead * (1 - cornerPressure * 0.46),
129
+ EDGE_RECOVERY_MIN_LOOKAHEAD,
130
+ LOOKAHEAD_MAX_DISTANCE,
131
+ );
83
132
  const targetBase = pointAt(race.track, car.progress + lookahead);
133
+ const curvature = Math.max(
134
+ car.trackState.curvature,
135
+ targetBase.curvature,
136
+ previewCurvature,
137
+ );
84
138
  const lanePlan = planRacingLine(car, orderIndex, race);
85
- const recoveryBias = car.trackState.crossTrackError > race.track.width * 0.46 ? 0 : 1;
86
- const target = offsetTrackPoint(targetBase, lanePlan.offset * recoveryBias);
139
+ const cornerOffsetDamping = clamp(curvature / 0.0012, 0, 0.58);
140
+ const recoveryOffset = edgeGuard.side * edgeGuard.recoveryOffset;
141
+ const racingOffset = calculateRacingLineOffset(car, race, lookahead, curvature, edgeGuard);
142
+ const trafficPressure = lanePlan.attackCommitted || lanePlan.sameLaneAhead || lanePlan.actualLaneAhead || lanePlan.sideRisk ? 1 : 0;
143
+ const racingLineWeight = lanePlan.attackCommitted ? 0.18 : trafficPressure ? 0.52 : 1;
144
+ const cornerOffsetFactor = 1 - cornerOffsetDamping * (lanePlan.attackCommitted ? 0.28 : trafficPressure ? 0.58 : 1);
145
+ const targetOffset = clamp(
146
+ (lanePlan.offset + racingOffset * racingLineWeight) * cornerOffsetFactor * (1 - edgeGuard.pressure * 0.8) +
147
+ recoveryOffset * edgeGuard.pressure * 0.8,
148
+ -edgeGuard.recoveryOffset,
149
+ edgeGuard.recoveryOffset,
150
+ );
151
+ const target = offsetTrackPoint(targetBase, targetOffset);
87
152
  const angleError = angleToPoint(car, target);
88
- const curvature = Math.max(car.trackState.curvature, targetBase.curvature);
89
- const gripBudget = 54 + car.racecraft * 11 + (car.tireEnergy ?? 100) * 0.05 + aggression * 4.5;
90
- const cornerTarget = clamp(
91
- Math.sqrt(gripBudget / Math.max(curvature, 0.0001)) + (car.pace - 1) * 20 + aggression * 7,
92
- 72,
93
- 168,
94
- );
95
- const edgeTolerance = race.track.width * (0.36 + aggression * 0.1);
96
- const edgePenalty = Math.max(0, car.trackState.crossTrackError - edgeTolerance) * (0.16 - aggression * 0.05);
153
+ const headingError = normalizeAngle(targetBase.heading - car.heading);
154
+ const lateralError = car.trackState?.signedOffset ?? 0;
155
+ const cornerCommitment = clamp(Math.abs(headingError) / 0.9, 0, 0.72);
156
+ const lateralCorrection = -Math.atan2(
157
+ lateralError * (0.85 + edgeGuard.pressure * 0.5),
158
+ Math.max(car.speed, kphToSimSpeed(35)),
159
+ ) * (1 - cornerCommitment * 0.62);
160
+ const headingFeedForward = clamp(
161
+ headingError * (0.34 + car.racecraft * 0.18 + edgeGuard.pressure * 0.12),
162
+ -0.42,
163
+ 0.42,
164
+ );
165
+ let pathSteer = angleError * (0.72 + car.racecraft * 0.12 + edgeGuard.pressure * 0.42) + headingFeedForward;
166
+ if (edgeGuard.side !== 0 && Math.sign(pathSteer) === edgeGuard.side) {
167
+ pathSteer *= 1 - edgeGuard.pressure * 0.85;
168
+ }
169
+ const edgeRecoverySteer = -edgeGuard.side * clamp(
170
+ (edgeGuard.pressure - 0.22) * 1.18 + edgeGuard.overLimitPressure * 0.55,
171
+ 0,
172
+ 0.52,
173
+ );
174
+ const steeringLimit = car.trackState.surface === 'kerb'
175
+ ? VEHICLE_LIMITS.maxSteer * 0.96
176
+ : VEHICLE_LIMITS.maxSteer;
177
+ const steeringRequest = clamp(pathSteer + lateralCorrection + edgeRecoverySteer, -steeringLimit, steeringLimit);
178
+ const cornerRadiusMeters = simUnitsToMeters(1 / Math.max(curvature, 1e-7));
179
+ const lateralGripBudget = 14.8 + car.racecraft * 3.4 + (car.tireEnergy ?? 100) * 0.011 + aggression * 1.8;
180
+ const cornerTargetKph = clamp(
181
+ Math.sqrt(lateralGripBudget * cornerRadiusMeters) * 3.6 * (1.1 + aggression * 0.08) +
182
+ (car.pace - 1) * 20 + aggression * 10,
183
+ 96,
184
+ 318,
185
+ );
186
+ const edgePenalty = edgeGuard.pressure > 0.42
187
+ ? simUnitsToMeters(Math.max(0, car.trackState.crossTrackError - edgeGuard.softLimit)) *
188
+ (5.8 - aggression * 1.1) + edgeGuard.overLimitPressure * 48
189
+ : edgeGuard.overLimitPressure * 48;
190
+ const steeringPenalty = clamp((Math.abs(angleError) - 0.38) * 24, 0, 12);
191
+ const headingPenalty = clamp((Math.abs(headingError) - 0.48) * 42, 0, 24);
192
+ const recoveryAlignmentPenalty = edgeGuard.pressure > 0.2
193
+ ? clamp((Math.abs(headingError) - 0.46) * 64, 0, 38)
194
+ : 0;
195
+ const steeringLoadPenalty = clamp(
196
+ (Math.abs(steeringRequest) / Math.max(steeringLimit, 1e-6) - 0.82) * 42,
197
+ 0,
198
+ 18,
199
+ ) * clamp(Math.abs(headingError) / 0.48, 0, 1);
200
+ const actualLaneLateral = lanePlan.actualLaneAhead
201
+ ? Math.abs(lanePlan.actualLaneAhead.signedOffset - (car.trackState?.signedOffset ?? 0))
202
+ : Infinity;
203
+ const plannedPassingOverlap = lanePlan.attackCommitted && lanePlan.sameLaneAhead
204
+ ? Math.abs(lanePlan.sameLaneAhead.signedOffset - (car.trackState?.signedOffset ?? 0)) > OVERTAKE_LATERAL_MIN
205
+ : false;
97
206
  const trafficPenalty = Math.max(
98
- lanePlan.sameLaneAhead ? clamp((230 - lanePlan.sameLaneAhead.gap) * 0.16, 0, 32) : 0,
99
- lanePlan.sideRisk ? clamp((44 - lanePlan.sideRisk.lateral) * 0.42, 0, 16) : 0,
207
+ lanePlan.sameLaneAhead
208
+ ? calculatePlannedTrafficPenalty(lanePlan.sameLaneAhead, plannedPassingOverlap)
209
+ : 0,
210
+ lanePlan.actualLaneAhead && actualLaneLateral < OVERTAKE_LATERAL_MIN
211
+ ? calculateActualOverlapPenalty(car, lanePlan.actualLaneAhead)
212
+ : 0,
213
+ lanePlan.sideRisk ? clamp(simUnitsToMeters(TRAFFIC_SIDE_GAP - lanePlan.sideRisk.lateral) * 0.42, 0, 16) : 0,
100
214
  ) * (1 - aggression * 0.28);
215
+ const minimumDesiredSpeedKph = edgeGuard.pressure > 0.55
216
+ ? 34
217
+ : car.trackState.surface === 'kerb' ? 58 : 52;
218
+ const desiredSpeedKph = clamp(
219
+ (car.drsActive ? cornerTargetKph + 22 : cornerTargetKph) -
220
+ edgePenalty -
221
+ steeringPenalty -
222
+ headingPenalty -
223
+ recoveryAlignmentPenalty -
224
+ steeringLoadPenalty -
225
+ trafficPenalty,
226
+ minimumDesiredSpeedKph,
227
+ 330,
228
+ );
101
229
  const desiredSpeed = clamp(
102
- (car.drsActive ? cornerTarget + 22 : cornerTarget) - edgePenalty - trafficPenalty,
103
- 58,
230
+ kphToSimSpeed(desiredSpeedKph),
231
+ kphToSimSpeed(minimumDesiredSpeedKph),
104
232
  VEHICLE_LIMITS.maxSpeed,
105
233
  );
106
234
  const speedError = desiredSpeed - car.speed;
235
+ const minimumThrottle = edgeGuard.pressure > 0.2 ? 0 : 0.1 + aggression * 0.08;
236
+ const brakeLimit = car.trackState.surface === 'kerb'
237
+ ? 0.42
238
+ : 0.72 - aggression * 0.08;
239
+ let brakeAmount = speedError < -kphToSimSpeed(3)
240
+ ? clamp(Math.abs(speedError) / kphToSimSpeed(28 + aggression * 11), 0, brakeLimit)
241
+ : 0;
242
+ if (edgeGuard.pressure > 0.46 && car.speed > kphToSimSpeed(48)) {
243
+ brakeAmount = Math.max(brakeAmount, clamp((edgeGuard.pressure - 0.42) * 0.9, 0, brakeLimit));
244
+ }
245
+ const recoveryThrottleScale = edgeGuard.pressure > 0.24
246
+ ? 1 - clamp((Math.abs(headingError) - 0.38) / 0.92, 0, 0.82)
247
+ : 1;
248
+ const throttleRequest = speedError > kphToSimSpeed(1)
249
+ ? clamp(speedError / kphToSimSpeed(16), minimumThrottle, 1)
250
+ : 0;
107
251
 
108
252
  return createDriverInput()
109
- .steer(angleError * (0.82 + car.racecraft * 0.1))
110
- .accelerate(speedError > 1 ? clamp(speedError / 16, 0.1 + aggression * 0.08, 1) : 0)
111
- .brake(speedError < -2 ? clamp(Math.abs(speedError) / (22 + aggression * 8), 0, 1) : 0)
253
+ .steer(steeringRequest)
254
+ .accelerate(brakeAmount > 0.05 ? 0 : throttleRequest * recoveryThrottleScale)
255
+ .brake(brakeAmount)
112
256
  .controls();
113
257
  }
114
258
 
115
- function decideRejoinControls(car, race) {
116
- const lookahead = clamp(car.speed * 0.72 + 118, 118, 220);
259
+ function maxLookaheadCurvature(track, progress, lookahead) {
260
+ let maximum = 0;
261
+ let previous = pointAt(track, progress);
262
+ for (let index = 1; index <= CURVATURE_LOOKAHEAD_SAMPLES; index += 1) {
263
+ const sample = pointAt(track, progress + lookahead * (index / CURVATURE_LOOKAHEAD_SAMPLES));
264
+ const segmentDistance = Math.max(1, lookahead / CURVATURE_LOOKAHEAD_SAMPLES);
265
+ const segmentCurvature = Math.abs(normalizeAngle(sample.heading - previous.heading)) / segmentDistance;
266
+ maximum = Math.max(maximum, Math.abs(sample.curvature ?? 0), Math.min(segmentCurvature, PREVIEW_CURVATURE_CAP));
267
+ previous = sample;
268
+ }
269
+ return maximum;
270
+ }
271
+
272
+ function calculateRacingLineOffset(car, race, lookahead, curvature, edgeGuard) {
273
+ const cornerStrength = clamp((curvature - 0.00012) / 0.00095, 0, 1);
274
+ if (cornerStrength <= 0) return 0;
275
+
276
+ const entry = pointAt(race.track, car.progress - lookahead * 0.35);
277
+ const apex = pointAt(race.track, car.progress + lookahead * 0.55);
278
+ const exit = pointAt(race.track, car.progress + lookahead * 1.25);
279
+ const signedEntry = normalizeAngle(apex.heading - entry.heading);
280
+ const signedExit = normalizeAngle(exit.heading - apex.heading);
281
+ const turnDirection = Math.sign(Math.abs(signedEntry) > Math.abs(signedExit) ? signedEntry : signedExit);
282
+ if (turnDirection === 0) return 0;
283
+
284
+ const aggression = car.aggression ?? car.personality?.baseAggression ?? 0.5;
285
+ const safeEdge = race.track.width / 2 - VEHICLE_LIMITS.carWidth * (1.15 - aggression * 0.12);
286
+ const apexOffset = turnDirection * safeEdge * (0.72 + aggression * 0.08);
287
+
288
+ return apexOffset * cornerStrength * (1 - edgeGuard.pressure * 0.65);
289
+ }
290
+
291
+ function calculateTrackEdgeGuard(car, race) {
292
+ const trackLimit = race.track.width / 2;
293
+ const halfCarWidth = VEHICLE_LIMITS.carWidth / 2;
294
+ const wholeCarOutsideLimit = trackLimit + halfCarWidth;
295
+ const softLimit = trackLimit - halfCarWidth * 1.9;
296
+ const recoveryOffset = trackLimit - halfCarWidth * 1.62;
297
+ const crossTrackError = car.trackState?.crossTrackError ?? 0;
298
+ const side = Math.sign(car.trackState?.signedOffset ?? 0);
299
+ const pressure = clamp(
300
+ (crossTrackError - softLimit) / Math.max(1, wholeCarOutsideLimit - softLimit),
301
+ 0,
302
+ 1,
303
+ );
304
+ const overLimitPressure = clamp(
305
+ (crossTrackError - wholeCarOutsideLimit) / Math.max(1, (race.track.kerbWidth ?? 0) + halfCarWidth),
306
+ 0,
307
+ 1,
308
+ );
309
+
310
+ return {
311
+ softLimit,
312
+ recoveryOffset,
313
+ pressure,
314
+ overLimitPressure,
315
+ side,
316
+ };
317
+ }
318
+
319
+ function shouldContinueRejoinRecovery(car, race) {
320
+ const frames = Math.max(0, Math.floor(car.rejoinRecoveryFrames ?? 0));
321
+ if (frames <= 0) return false;
322
+
323
+ const lookahead = clamp(car.speed * 0.66 + REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_MAX);
117
324
  const targetBase = pointAt(race.track, car.progress + lookahead);
118
- const target = offsetTrackPoint(targetBase, 0);
325
+ const headingError = Math.abs(normalizeAngle(targetBase.heading - car.heading));
326
+ const edgeBuffer = race.track.width / 2 - VEHICLE_LIMITS.carWidth * 1.35;
327
+ const stillUnsettled = car.trackState.crossTrackError > edgeBuffer || headingError > 0.34;
328
+
329
+ car.rejoinRecoveryFrames = stillUnsettled ? frames - 1 : 0;
330
+ return stillUnsettled;
331
+ }
332
+
333
+ function decideRejoinControls(car, race) {
334
+ const lookahead = clamp(car.speed * 0.72 + REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_MAX);
335
+ const distanceFromRoadMeters = simUnitsToMeters(Math.max(0, car.trackState.crossTrackError - race.track.width / 2));
336
+ const targetBase = !car.trackState.onTrack && distanceFromRoadMeters > 2.5
337
+ ? pointAt(race.track, car.progress)
338
+ : pointAt(race.track, car.progress + lookahead * 0.78);
339
+ const signedSide = Math.sign(car.trackState?.signedOffset ?? 0);
340
+ const rejoinOffset = signedSide !== 0 && (
341
+ distanceFromRoadMeters > 0.5 ||
342
+ car.trackState.crossTrackError > race.track.width / 2 - VEHICLE_LIMITS.carWidth * 1.6
343
+ )
344
+ ? -signedSide * race.track.width * 0.22
345
+ : 0;
346
+ const target = offsetTrackPoint(targetBase, rejoinOffset);
119
347
  const angleError = angleToPoint(car, target);
120
- const distanceFromRoad = Math.max(0, car.trackState.crossTrackError - race.track.width / 2);
121
- const surfaceTargetSpeed = car.trackState.surface === 'gravel'
122
- ? 54
123
- : car.trackState.surface === 'grass'
124
- ? 44
125
- : 30;
126
- const desiredSpeed = clamp(surfaceTargetSpeed - distanceFromRoad * 0.045, 20, surfaceTargetSpeed);
348
+ const surfaceTargetSpeedKph = car.trackState.surface === 'track'
349
+ ? 88
350
+ : car.trackState.surface === 'kerb'
351
+ ? 72
352
+ : car.trackState.surface === 'gravel'
353
+ ? 56
354
+ : car.trackState.surface === 'grass'
355
+ ? 48
356
+ : 34;
357
+ const alignment = clamp(1 - Math.abs(angleError) / Math.PI, 0.22, 1);
358
+ const desiredSpeed = kphToSimSpeed(clamp(
359
+ surfaceTargetSpeedKph * (0.62 + alignment * 0.38) - distanceFromRoadMeters * 0.065,
360
+ car.trackState.surface === 'barrier' ? 12 : 24,
361
+ surfaceTargetSpeedKph,
362
+ ));
127
363
  const speedError = desiredSpeed - car.speed;
128
- const alignment = clamp(1 - Math.abs(angleError) / Math.PI, 0.28, 1);
129
- const lowSpeedRecovery = car.speed < 18 ? 0.16 : 0;
130
- const throttleLimit = car.trackState.surface === 'gravel' ? 0.62 : 0.48;
364
+ const lowSpeedRecovery = car.speed < kphToSimSpeed(18)
365
+ ? car.trackState.surface === 'barrier' ? 0.34 : 0.18
366
+ : 0;
367
+ const throttleLimit = car.trackState.surface === 'barrier'
368
+ ? 0.68
369
+ : car.trackState.surface === 'gravel' ? 0.46 : 0.42;
370
+ const brakeAmount = speedError < -kphToSimSpeed(5)
371
+ ? clamp(Math.abs(speedError) / kphToSimSpeed(30), 0.04, car.trackState.onTrack ? 0.46 : 0.74)
372
+ : 0;
131
373
 
132
374
  return createDriverInput()
133
- .steer(angleError * 0.92)
134
- .accelerate(speedError > 0 ? clamp((speedError / 24) * alignment + lowSpeedRecovery, 0.08, throttleLimit) : lowSpeedRecovery)
135
- .brake(speedError < -5 ? clamp(Math.abs(speedError) / 30, 0.04, 0.74) : 0)
375
+ .steer(angleError * 1.65)
376
+ .accelerate(brakeAmount > 0.05 ? 0 : speedError > 0
377
+ ? clamp((speedError / kphToSimSpeed(24)) * alignment + lowSpeedRecovery, 0.08, throttleLimit)
378
+ : lowSpeedRecovery)
379
+ .brake(brakeAmount)
136
380
  .controls();
137
381
  }
138
382
 
139
383
  function decideSafetyCarControls(car, orderIndex, race) {
140
384
  const queueSlot = race.safetyCar.progress - race.rules.safetyCarLeadDistance - orderIndex * race.rules.safetyCarGap;
141
- const lookahead = clamp(car.speed * 0.8 + 74, 84, 148);
385
+ const lookahead = clamp(car.speed * 0.55 + metersToSimUnits(20), SAFETY_CAR_LOOKAHEAD_MIN, SAFETY_CAR_LOOKAHEAD_MAX);
142
386
  const targetBase = pointAt(race.track, car.progress + lookahead);
143
387
  const target = offsetTrackPoint(targetBase, 0);
144
388
  const slotError = queueSlot - car.raceDistance;
389
+ const headingError = normalizeAngle(targetBase.heading - car.heading);
390
+ const lateralError = car.trackState?.signedOffset ?? 0;
391
+ const centerlineCorrection = -Math.atan2(
392
+ lateralError * 1.25,
393
+ Math.max(car.speed, kphToSimSpeed(35)),
394
+ );
395
+ const cornerRadiusMeters = simUnitsToMeters(1 / Math.max(Math.abs(targetBase.curvature), 1e-7));
396
+ const cornerSpeed = kphToSimSpeed(clamp(Math.sqrt(4.2 * cornerRadiusMeters) * 3.6, 45, 158));
397
+ const edgePenalty = clamp(simUnitsToMeters(Math.abs(lateralError)) - simUnitsToMeters(race.track.width / 2) * 0.68, 0, 3) * kphToSimSpeed(18);
145
398
  const desiredSpeed = clamp(
146
- race.rules.safetyCarSpeed + slotError * 0.24,
147
- 22,
148
- race.rules.safetyCarSpeed + 32,
399
+ race.rules.safetyCarSpeed + slotError * 0.24 - edgePenalty,
400
+ kphToSimSpeed(22),
401
+ Math.min(race.rules.safetyCarSpeed + kphToSimSpeed(32), cornerSpeed),
149
402
  );
150
403
  const speedError = desiredSpeed - car.speed;
151
404
 
152
405
  return createDriverInput()
153
- .steer(angleToPoint(car, target) * 1.04)
154
- .accelerate(speedError > 1 ? clamp(speedError / 16, 0, 0.5) : 0)
155
- .brake(speedError < -0.5 ? clamp(Math.abs(speedError) / 14, 0, 1) : 0)
406
+ .steer(angleToPoint(car, target) * 1.18 + headingError * 0.32 + centerlineCorrection)
407
+ .accelerate(speedError > kphToSimSpeed(1) ? clamp(speedError / kphToSimSpeed(16), 0, 0.5) : 0)
408
+ .brake(speedError < -kphToSimSpeed(0.5) ? clamp(Math.abs(speedError) / kphToSimSpeed(14), 0, 1) : 0)
156
409
  .controls();
157
410
  }
158
411
 
159
412
  export function planRacingLine(car, orderIndex, race) {
160
413
  const aggression = car.aggression ?? car.personality?.baseAggression ?? 0.5;
161
414
  const riskTolerance = car.personality?.riskTolerance ?? aggression;
162
- const trackLimit = race.track.width / 2 - VEHICLE_LIMITS.carWidth * clamp(1.22 - aggression * 0.34, 0.84, 1.22);
163
- const preferred = Math.sin((car.index / Math.max(1, race.cars.length)) * TWO_PI) * 26;
415
+ const trackLimit = race.track.width / 2 - VEHICLE_LIMITS.carWidth * clamp(1.55 - aggression * 0.28, 1.26, 1.55);
416
+ const preferred = Math.sin((car.index / Math.max(1, race.cars.length)) * TWO_PI) * metersToSimUnits(1.1);
164
417
  const currentOffset = clamp(car.desiredOffset ?? preferred, -trackLimit, trackLimit);
165
418
  const ahead = race.orderedCars[orderIndex - 1];
166
419
  const traffic = scanNearbyTraffic(car, race);
420
+ const rearThreat = findDefensiveThreat(traffic);
421
+ const attackPlan = planAttackCommitment(car, ahead, traffic, trackLimit, aggression, race);
167
422
 
168
423
  let bestOffset = currentOffset;
169
424
  let bestScore = -Infinity;
@@ -174,30 +429,49 @@ export function planRacingLine(car, orderIndex, race) {
174
429
  let score = 80;
175
430
  score -= Math.abs(offset - preferred) * 0.18;
176
431
  score -= Math.abs(offset - currentOffset) * (0.09 - aggression * 0.025);
177
- score -= Math.max(0, 18 - edgeClearance) * (0.92 - aggression * 0.42);
432
+ score -= Math.max(0, LANE_EDGE_CLEARANCE_TARGET - edgeClearance) * (0.92 - aggression * 0.42);
178
433
 
179
434
  traffic.forEach((entry) => {
180
435
  const lateral = Math.abs(entry.signedOffset - offset);
181
- if (entry.gap > 0 && entry.gap < 260) {
182
- const overlapRisk = clamp(58 - lateral, 0, 58);
183
- score -= overlapRisk * (260 - entry.gap) * 0.038 * (1 - riskTolerance * 0.34);
184
- if (entry.gap < 190 + aggression * 70 && lateral > 34 - aggression * 8) {
185
- score += Math.min(30 + aggression * 18, lateral - (28 - aggression * 6)) * (0.62 + aggression * 0.82);
436
+ if (entry.gap > 0 && entry.gap < TRAFFIC_GAP_AHEAD) {
437
+ const overlapRisk = clamp(TRAFFIC_SIDE_OVERLAP - lateral, 0, TRAFFIC_SIDE_OVERLAP);
438
+ score -= overlapRisk * (TRAFFIC_GAP_AHEAD - entry.gap) * 0.038 * (1 - riskTolerance * 0.34);
439
+ if (entry.gap < OVERTAKE_GAP_BASE + aggression * OVERTAKE_GAP_AGGRESSION && lateral > OVERTAKE_LATERAL_MIN - aggression * metersToSimUnits(4)) {
440
+ score += Math.min(metersToSimUnits(14) + aggression * metersToSimUnits(8.5), lateral - (OVERTAKE_LATERAL_REWARD - aggression * metersToSimUnits(3))) * (0.62 + aggression * 0.82);
186
441
  }
187
- } else if (entry.gap <= 0 && entry.gap > -74) {
188
- const sideOverlapRisk = clamp(52 - lateral, 0, 52);
189
- score -= sideOverlapRisk * (74 + entry.gap) * 0.052 * (1 - riskTolerance * 0.22);
442
+ } else if (entry.gap <= 0 && entry.gap > -TRAFFIC_SIDE_OVERLAP) {
443
+ const sideOverlapRisk = clamp(TRAFFIC_SIDE_OVERLAP - lateral, 0, TRAFFIC_SIDE_OVERLAP);
444
+ score -= sideOverlapRisk * (TRAFFIC_SIDE_OVERLAP + entry.gap) * 0.052 * (1 - riskTolerance * 0.22);
190
445
  }
191
446
  });
192
447
 
193
- if (ahead && car.gapAhead < 230) {
194
- const side = car.index % 2 === 0 ? -1 : 1;
195
- const passSide = clamp(
196
- (ahead.trackState.signedOffset * -0.65) + side * (48 + aggression * 42),
448
+ if (ahead && car.gapAhead < TRAFFIC_GAP_AHEAD) {
449
+ const passSide = attackPlan?.targetOffset ?? clamp(
450
+ (ahead.trackState.signedOffset * -0.65) + (car.index % 2 === 0 ? -1 : 1) * (PASS_SIDE_BASE + aggression * PASS_SIDE_AGGRESSION),
451
+ -trackLimit,
452
+ trackLimit,
453
+ );
454
+ const attackPressure = attackPlan?.pressure ?? 0.45;
455
+ score -= Math.abs(offset - passSide) * (0.12 + aggression * 0.16) * attackPressure;
456
+ if (attackPlan && Math.sign(offset || passSide) === attackPlan.side && Math.abs(offset - ahead.trackState.signedOffset) > OVERTAKE_LATERAL_MIN) {
457
+ score += metersToSimUnits(4 + aggression * 5) * attackPressure;
458
+ }
459
+ }
460
+
461
+ const defenseIntensity = clamp((aggression - 0.5) / 0.3, 0, 1) *
462
+ clamp((Math.max(riskTolerance, aggression) - 0.42) / 0.4, 0, 1);
463
+ if (rearThreat && defenseIntensity > 0.04) {
464
+ const threatSide = Math.sign(rearThreat.signedOffset || currentOffset || (car.index % 2 === 0 ? 1 : -1));
465
+ const defendOffset = clamp(
466
+ threatSide * Math.max(Math.abs(rearThreat.signedOffset) + metersToSimUnits(0.8), DEFEND_MIN_OFFSET),
197
467
  -trackLimit,
198
468
  trackLimit,
199
469
  );
200
- score -= Math.abs(offset - passSide) * (0.09 + aggression * 0.08);
470
+ const closingPressure = clamp((DEFEND_REAR_GAP + rearThreat.gap) / DEFEND_REAR_GAP, 0, 1);
471
+ score -= Math.abs(offset - defendOffset) * (0.75 + aggression * 0.45) * closingPressure * defenseIntensity;
472
+ if (Math.sign(offset) === threatSide && Math.abs(offset) >= DEFEND_MIN_OFFSET) {
473
+ score += metersToSimUnits(10) * (0.42 + aggression * 0.36) * closingPressure * defenseIntensity;
474
+ }
201
475
  }
202
476
 
203
477
  if (score > bestScore) {
@@ -206,16 +480,96 @@ export function planRacingLine(car, orderIndex, race) {
206
480
  }
207
481
  });
208
482
 
209
- const laneChangeRate = 0.72 + car.racecraft * 0.3 + aggression * 0.58;
483
+ const laneChangeRate = metersToSimUnits(0.95 + car.racecraft * 0.32 + aggression * 0.78);
210
484
  car.desiredOffset = currentOffset + clamp(bestOffset - currentOffset, -laneChangeRate, laneChangeRate);
211
485
 
212
486
  return {
213
487
  offset: car.desiredOffset,
214
- sameLaneAhead: findLaneTrafficAhead(traffic, car.desiredOffset, 230),
488
+ sameLaneAhead: findLaneTrafficAhead(traffic, car.desiredOffset, TRAFFIC_GAP_AHEAD),
489
+ actualLaneAhead: findLaneTrafficAhead(traffic, car.trackState?.signedOffset ?? car.desiredOffset, TRAFFIC_GAP_AHEAD),
215
490
  sideRisk: findLaneTrafficBeside(traffic, car.desiredOffset),
491
+ attackCommitted: Boolean(attackPlan),
216
492
  };
217
493
  }
218
494
 
495
+ function calculatePlannedTrafficPenalty(entry, plannedPassingOverlap) {
496
+ return clamp(
497
+ simUnitsToMeters(TRAFFIC_GAP_AHEAD - entry.gap) * (plannedPassingOverlap ? 0.08 : 0.16),
498
+ 0,
499
+ plannedPassingOverlap ? 24 : 32,
500
+ );
501
+ }
502
+
503
+ function calculateActualOverlapPenalty(car, entry) {
504
+ const gapMeters = simUnitsToMeters(entry.gap);
505
+ const closingKph = Math.max(0, simSpeedToKph(car.speed - (entry.speed ?? car.speed)));
506
+ const urgent = gapMeters < 9
507
+ ? (9 - gapMeters) * (2.2 + closingKph * 0.03)
508
+ : 0;
509
+
510
+ return clamp(urgent, 0, 58);
511
+ }
512
+
513
+ function planAttackCommitment(car, ahead, traffic, trackLimit, aggression, race) {
514
+ const existingFrames = Math.max(0, Math.floor(car.attackCommitmentFrames ?? 0));
515
+ const relativeSpeed = ahead ? car.speed - ahead.speed : 0;
516
+ const closeEnough = ahead &&
517
+ car.gapAhead > 0 &&
518
+ car.gapAhead < OVERTAKE_GAP_BASE + aggression * OVERTAKE_GAP_AGGRESSION + metersToSimUnits(28);
519
+ const speedPressure = clamp(relativeSpeed / kphToSimSpeed(34), -0.4, 1);
520
+
521
+ if (closeEnough && speedPressure > -0.15) {
522
+ const aheadOffset = ahead.trackState?.signedOffset ?? 0;
523
+ const currentSide = Math.sign(car.attackSide ?? 0);
524
+ const blockedCommitment = currentSide &&
525
+ Math.sign(aheadOffset || currentSide) === currentSide &&
526
+ Math.abs(aheadOffset - (car.trackState?.signedOffset ?? 0)) < TRAFFIC_SIDE_GAP &&
527
+ Math.abs(aheadOffset) > trackLimit * 0.55;
528
+
529
+ if (!existingFrames || !currentSide || blockedCommitment) {
530
+ const leftSpace = trackLimit + aheadOffset;
531
+ const rightSpace = trackLimit - aheadOffset;
532
+ const preferredSide = leftSpace > rightSpace ? -1 : 1;
533
+ const freeSide = chooseFreerAttackSide(traffic, preferredSide);
534
+ car.attackSide = freeSide;
535
+ }
536
+ car.attackCommitmentFrames = OVERTAKE_COMMIT_FRAMES;
537
+ } else if (existingFrames > 0) {
538
+ car.attackCommitmentFrames = existingFrames - 1;
539
+ } else {
540
+ car.attackSide = 0;
541
+ car.attackCommitmentFrames = 0;
542
+ }
543
+
544
+ const side = Math.sign(car.attackSide ?? 0);
545
+ if (!side || !ahead) return null;
546
+
547
+ const targetOffset = clamp(
548
+ (ahead.trackState?.signedOffset ?? 0) + side * (PASS_SIDE_BASE + aggression * PASS_SIDE_AGGRESSION + metersToSimUnits(2.4)),
549
+ -trackLimit,
550
+ trackLimit,
551
+ );
552
+ const pressure = clamp(
553
+ 0.48 +
554
+ aggression * 0.34 +
555
+ speedPressure * 0.28 +
556
+ (car.attackCommitmentFrames ?? 0) / OVERTAKE_COMMIT_FRAMES * 0.22,
557
+ 0.45,
558
+ 1.15,
559
+ );
560
+
561
+ return { side, targetOffset, pressure };
562
+ }
563
+
564
+ function chooseFreerAttackSide(traffic, preferredSide) {
565
+ const sideScore = (side) => traffic.reduce((score, entry) => {
566
+ if (entry.gap < -TRAFFIC_REAR_WINDOW || entry.gap > TRAFFIC_GAP_AHEAD) return score;
567
+ const sameSide = Math.sign(entry.signedOffset || side) === side;
568
+ return score - (sameSide ? 1 : 0) * (1 - clamp(Math.abs(entry.gap) / TRAFFIC_GAP_AHEAD, 0, 1));
569
+ }, side === preferredSide ? 0.18 : 0);
570
+ return sideScore(1) >= sideScore(-1) ? 1 : -1;
571
+ }
572
+
219
573
  function scanNearbyTraffic(car, race) {
220
574
  return race.cars
221
575
  .filter((other) => other !== car)
@@ -223,8 +577,20 @@ function scanNearbyTraffic(car, race) {
223
577
  car: other,
224
578
  gap: other.raceDistance - car.raceDistance,
225
579
  signedOffset: other.trackState?.signedOffset ?? 0,
580
+ speed: other.speed,
226
581
  }))
227
- .filter((entry) => entry.gap > -82 && entry.gap < 280);
582
+ .filter((entry) => entry.gap > -metersToSimUnits(39) && entry.gap < metersToSimUnits(134));
583
+ }
584
+
585
+ function findDefensiveThreat(traffic) {
586
+ let closest = null;
587
+
588
+ traffic.forEach((entry) => {
589
+ if (entry.gap >= -VEHICLE_LIMITS.carLength * 1.1 || entry.gap < -DEFEND_REAR_GAP) return;
590
+ if (!closest || entry.gap > closest.gap) closest = entry;
591
+ });
592
+
593
+ return closest;
228
594
  }
229
595
 
230
596
  function findLaneTrafficAhead(traffic, offset, maxDistance) {
@@ -232,7 +598,7 @@ function findLaneTrafficAhead(traffic, offset, maxDistance) {
232
598
 
233
599
  traffic.forEach((entry) => {
234
600
  if (entry.gap <= 0 || entry.gap > maxDistance) return;
235
- if (Math.abs(entry.signedOffset - offset) > 42) return;
601
+ if (Math.abs(entry.signedOffset - offset) > TRAFFIC_SIDE_GAP) return;
236
602
  if (!closest || entry.gap < closest.gap) closest = entry;
237
603
  });
238
604
 
@@ -244,8 +610,8 @@ function findLaneTrafficBeside(traffic, offset) {
244
610
 
245
611
  traffic.forEach((entry) => {
246
612
  const lateral = Math.abs(entry.signedOffset - offset);
247
- if (Math.abs(entry.gap) > 58 || lateral > 44) return;
248
- const risk = (58 - Math.abs(entry.gap)) + (44 - lateral);
613
+ if (Math.abs(entry.gap) > metersToSimUnits(28) || lateral > TRAFFIC_SIDE_GAP) return;
614
+ const risk = (metersToSimUnits(28) - Math.abs(entry.gap)) + (TRAFFIC_SIDE_GAP - lateral);
249
615
  if (!closest || risk > closest.risk) closest = { ...entry, lateral, risk };
250
616
  });
251
617