@inventure71/paddockjs 0.2.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.
- package/CHANGELOG.md +45 -0
- package/INSTALL_AND_UPDATE.md +165 -0
- package/README.md +217 -20
- package/docs/architecture.md +84 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +412 -20
- package/docs/data_model.md +2 -1
- package/docs/index.md +1 -0
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +74 -20
- package/docs/training.md +206 -0
- package/examples/train-basic-policy.mjs +276 -0
- package/examples/trainingData.mjs +133 -0
- package/package.json +18 -8
- package/src/api/PaddockSimulatorController.js +39 -4
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +1251 -146
- package/src/app/domBindings.js +10 -2
- package/src/config/defaultOptions.js +11 -3
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +68 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/evaluation.js +184 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +521 -0
- package/src/environment/index.js +15 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +284 -0
- package/src/environment/options.js +157 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +175 -0
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +367 -0
- package/src/environment/specs.js +163 -0
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +405 -6
- package/src/index.js +27 -2
- package/src/rendering/proceduralTrackAsset.js +343 -24
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2140 -171
- package/src/simulation/rules/collisionSteward.js +69 -0
- package/src/simulation/rules/penaltyLedger.js +153 -0
- package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
- package/src/simulation/rules/tireRequirementSteward.js +29 -0
- package/src/simulation/rules/trackLimitsSteward.js +85 -0
- package/src/simulation/rulesConfig.js +352 -0
- package/src/simulation/trackModel.js +1154 -76
- package/src/simulation/units.js +6 -4
- package/src/simulation/vehicleGeometry.js +247 -0
- package/src/simulation/vehiclePhysics.js +82 -50
- package/src/simulation/wheelSurface.js +338 -0
- package/src/styles.css +369 -67
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +45 -81
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -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 = [-
|
|
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
|
|
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
|
|
86
|
-
const
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
(
|
|
103
|
-
|
|
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(
|
|
110
|
-
.accelerate(
|
|
111
|
-
.brake(
|
|
253
|
+
.steer(steeringRequest)
|
|
254
|
+
.accelerate(brakeAmount > 0.05 ? 0 : throttleRequest * recoveryThrottleScale)
|
|
255
|
+
.brake(brakeAmount)
|
|
112
256
|
.controls();
|
|
113
257
|
}
|
|
114
258
|
|
|
115
|
-
function
|
|
116
|
-
|
|
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
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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 *
|
|
134
|
-
.accelerate(
|
|
135
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
163
|
-
const preferred = Math.sin((car.index / Math.max(1, race.cars.length)) * TWO_PI) *
|
|
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,
|
|
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 <
|
|
182
|
-
const overlapRisk = clamp(
|
|
183
|
-
score -= overlapRisk * (
|
|
184
|
-
if (entry.gap <
|
|
185
|
-
score += Math.min(
|
|
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 > -
|
|
188
|
-
const sideOverlapRisk = clamp(
|
|
189
|
-
score -= sideOverlapRisk * (
|
|
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 <
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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 > -
|
|
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) >
|
|
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) >
|
|
248
|
-
const risk = (
|
|
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
|
|