@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.
- package/CHANGELOG.md +39 -0
- package/INSTALL_AND_UPDATE.md +3 -1
- package/README.md +142 -9
- package/docs/architecture.md +49 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +185 -27
- package/docs/data_model.md +1 -1
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +39 -16
- package/docs/training.md +103 -1
- package/examples/train-basic-policy.mjs +5 -6
- package/examples/trainingData.mjs +133 -0
- package/package.json +5 -2
- package/src/api/PaddockSimulatorController.js +32 -2
- package/src/app/F1SimulatorApp.js +1101 -128
- package/src/app/domBindings.js +10 -1
- package/src/config/defaultOptions.js +4 -4
- package/src/environment/actions.js +25 -2
- package/src/environment/evaluation.js +184 -0
- package/src/environment/index.d.ts +281 -1
- package/src/environment/index.js +13 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +189 -17
- package/src/environment/options.js +7 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/runtime.js +41 -6
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +145 -35
- package/src/environment/specs.js +92 -8
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +366 -3
- package/src/index.js +21 -0
- package/src/rendering/proceduralTrackAsset.js +336 -23
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2132 -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 +300 -42
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +36 -96
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
|
@@ -8,32 +8,79 @@ import {
|
|
|
8
8
|
WORLD,
|
|
9
9
|
} from './trackModel.js';
|
|
10
10
|
import { buildDriverPersonality, decideDriverControls } from './driverController.js';
|
|
11
|
+
import { buildCollisionCandidatePairs, detectVehicleCollision } from './collisionGeometry.js';
|
|
12
|
+
import { calculateCollisionPenalties } from './rules/collisionSteward.js';
|
|
13
|
+
import {
|
|
14
|
+
applyUnservedServicePenalty,
|
|
15
|
+
cancelPenaltyRecord,
|
|
16
|
+
createPenaltyEvent,
|
|
17
|
+
createPenaltyRecord,
|
|
18
|
+
getPitServicePenaltySeconds,
|
|
19
|
+
isPenaltyActive,
|
|
20
|
+
isPenaltyPitServiceable,
|
|
21
|
+
serializePenalty,
|
|
22
|
+
servePitPenaltyRecord,
|
|
23
|
+
servePenaltyRecord,
|
|
24
|
+
} from './rules/penaltyLedger.js';
|
|
25
|
+
import { calculateTireRequirementPenalty } from './rules/tireRequirementSteward.js';
|
|
26
|
+
import { calculateTrackLimitReview } from './rules/trackLimitsSteward.js';
|
|
27
|
+
import { calculatePitLaneSpeedingReview } from './rules/pitLaneSpeedingSteward.js';
|
|
28
|
+
import { DEFAULT_RULES, getPenaltyRule, normalizeRaceRules } from './rulesConfig.js';
|
|
11
29
|
import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
|
|
12
|
-
import { simSpeedToKph, simUnitsToMeters } from './units.js';
|
|
13
|
-
import {
|
|
30
|
+
import { kphToSimSpeed, metersToSimUnits, simSpeedToKph, simUnitsToMeters } from './units.js';
|
|
31
|
+
import { integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
|
|
32
|
+
import { applyWheelSurfaceState } from './wheelSurface.js';
|
|
14
33
|
|
|
15
34
|
const DEFAULT_TOTAL_LAPS = 10;
|
|
16
35
|
export const FIXED_STEP = 1 / 60;
|
|
17
36
|
const MIN_TOTAL_LAPS = 1;
|
|
18
37
|
const MAX_COLLISION_CORRECTION = 4.5;
|
|
19
|
-
const GRID_SLOT_SPACING =
|
|
20
|
-
const
|
|
21
|
-
const
|
|
38
|
+
const GRID_SLOT_SPACING = metersToSimUnits(8);
|
|
39
|
+
const ROLLING_START_SLOT_SPACING = metersToSimUnits(35);
|
|
40
|
+
const GRID_FIRST_SLOT_DISTANCE = metersToSimUnits(-6);
|
|
41
|
+
const ROLLING_START_FIRST_DISTANCE = metersToSimUnits(-30);
|
|
42
|
+
const GRID_LATERAL_OFFSET = metersToSimUnits(3.2);
|
|
22
43
|
const TIMING_HISTORY_WINDOW_SECONDS = 18;
|
|
23
44
|
const TIMING_HISTORY_MAX_SAMPLES = 720;
|
|
45
|
+
const TIMING_LINE_TARGET_SPACING_METERS = 175;
|
|
46
|
+
const TIMING_LINE_HISTORY_LAPS = 3;
|
|
24
47
|
const TELEMETRY_SECTOR_COUNT = 3;
|
|
48
|
+
const PIT_ENTRY_APPROACH_DISTANCE = metersToSimUnits(250);
|
|
49
|
+
const PIT_ROUTE_LOOKAHEAD_MIN = metersToSimUnits(35);
|
|
50
|
+
const PIT_ROUTE_LOOKAHEAD_MAX = metersToSimUnits(88);
|
|
51
|
+
const PIT_ROUTE_FINISH_DISTANCE = metersToSimUnits(8.5);
|
|
52
|
+
const PIT_QUEUE_RELEASE_FINISH_DISTANCE = metersToSimUnits(4);
|
|
53
|
+
const PIT_QUEUE_CAPTURE_DISTANCE = metersToSimUnits(2.5);
|
|
54
|
+
const PIT_ENTRY_BOX_CAPTURE_DISTANCE = metersToSimUnits(20);
|
|
55
|
+
const PIT_BOX_STOP_SPEED = kphToSimSpeed(35);
|
|
56
|
+
const PIT_QUEUE_CAPTURE_SPEED = kphToSimSpeed(140);
|
|
57
|
+
const PIT_QUEUE_RELEASE_SPEED = kphToSimSpeed(30);
|
|
58
|
+
const PIT_SERVICE_CLEAR_DISTANCE = VEHICLE_LIMITS.carLength * 0.9;
|
|
59
|
+
const PIT_EXIT_RELEASE_SPEED_KPH = 95;
|
|
60
|
+
const PIT_LIMITER_BRAKE_DISTANCE = metersToSimUnits(295);
|
|
61
|
+
const PIT_LIMITER_APPROACH_SPEED_SLOPE = 0.045;
|
|
62
|
+
const PIT_ENTRY_CONNECTOR_OVERSPEED_KPH = 75;
|
|
63
|
+
const PIT_DRIVE_LANE_OFFSET_RATIO = 0.28;
|
|
64
|
+
const PIT_BOX_APPROACH_DISTANCE = metersToSimUnits(34);
|
|
65
|
+
const PIT_SERVICE_QUEUE_FALLBACK_GAP = metersToSimUnits(48);
|
|
66
|
+
const PIT_INTENT_NONE = 0;
|
|
67
|
+
const PIT_INTENT_IF_FREE = 1;
|
|
68
|
+
const PIT_INTENT_COMMITTED = 2;
|
|
69
|
+
|
|
70
|
+
export { DEFAULT_RULES };
|
|
71
|
+
|
|
72
|
+
function getStartGridSlot(index, { standingStart = false } = {}) {
|
|
73
|
+
const gridDistance = standingStart
|
|
74
|
+
? GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING
|
|
75
|
+
: ROLLING_START_FIRST_DISTANCE - index * ROLLING_START_SLOT_SPACING;
|
|
76
|
+
const gridOffset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
|
|
77
|
+
return { gridDistance, gridOffset };
|
|
78
|
+
}
|
|
25
79
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
safetyCarGap: 128,
|
|
31
|
-
collisionRestitution: 0.18,
|
|
32
|
-
standingStart: true,
|
|
33
|
-
startLightCount: 5,
|
|
34
|
-
startLightInterval: 0.72,
|
|
35
|
-
startLightsOutHold: 0.78,
|
|
36
|
-
};
|
|
80
|
+
function isPitPositionControlledCar(car) {
|
|
81
|
+
const status = car?.pitStop?.status;
|
|
82
|
+
return status === 'entering' || status === 'queued' || status === 'servicing' || status === 'exiting';
|
|
83
|
+
}
|
|
37
84
|
|
|
38
85
|
function wrapProgress(value, length) {
|
|
39
86
|
return wrapDistance(value, length);
|
|
@@ -97,6 +144,15 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
|
|
|
97
144
|
};
|
|
98
145
|
}
|
|
99
146
|
|
|
147
|
+
function createSectorProgress(position, currentSectors = null) {
|
|
148
|
+
const activeIndex = clamp((position.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
|
|
149
|
+
return createEmptySectorTimes().map((_, index) => {
|
|
150
|
+
if (index < activeIndex && Number.isFinite(currentSectors?.[index])) return 1;
|
|
151
|
+
if (index === activeIndex) return clamp(position.currentSectorProgress ?? 0, 0, 1);
|
|
152
|
+
return 0;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
|
|
101
157
|
const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
|
|
102
158
|
return {
|
|
@@ -107,6 +163,8 @@ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps
|
|
|
107
163
|
currentLapTime: 0,
|
|
108
164
|
currentSectorElapsed: 0,
|
|
109
165
|
currentSectors: createEmptySectorTimes(),
|
|
166
|
+
sectorProgress: createSectorProgress(position),
|
|
167
|
+
liveSectors: createEmptySectorTimes(),
|
|
110
168
|
lastLapTime: null,
|
|
111
169
|
bestLapTime: null,
|
|
112
170
|
lastSectors: createEmptySectorTimes(),
|
|
@@ -135,6 +193,8 @@ function serializeLapTelemetry(telemetry) {
|
|
|
135
193
|
currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
|
|
136
194
|
currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
|
|
137
195
|
currentSectors: serializeSectorTimes(telemetry.currentSectors),
|
|
196
|
+
sectorProgress: serializeSectorTimes(telemetry.sectorProgress),
|
|
197
|
+
liveSectors: serializeSectorTimes(telemetry.liveSectors),
|
|
138
198
|
lastLapTime: finiteOrNull(telemetry.lastLapTime),
|
|
139
199
|
bestLapTime: finiteOrNull(telemetry.bestLapTime),
|
|
140
200
|
lastSectors: serializeSectorTimes(telemetry.lastSectors),
|
|
@@ -159,14 +219,36 @@ function updateBestSector(telemetry, sectorIndex, sectorTime) {
|
|
|
159
219
|
}
|
|
160
220
|
}
|
|
161
221
|
|
|
222
|
+
function clearFutureSectorTelemetry(telemetry) {
|
|
223
|
+
const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
|
|
224
|
+
for (let index = activeIndex; index < TELEMETRY_SECTOR_COUNT; index += 1) {
|
|
225
|
+
telemetry.currentSectors[index] = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function syncLiveSectorTelemetry(telemetry, track) {
|
|
230
|
+
const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
|
|
231
|
+
|
|
232
|
+
telemetry.liveSectors = createEmptySectorTimes();
|
|
233
|
+
telemetry.currentSectors.forEach((time, index) => {
|
|
234
|
+
if (index >= activeIndex) return;
|
|
235
|
+
if (!Number.isFinite(time)) return;
|
|
236
|
+
telemetry.liveSectors[index] = time;
|
|
237
|
+
});
|
|
238
|
+
telemetry.liveSectors[activeIndex] = telemetry.currentSectorElapsed;
|
|
239
|
+
}
|
|
240
|
+
|
|
162
241
|
function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
|
|
163
242
|
const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
|
|
164
243
|
telemetry.currentLap = position.currentLap;
|
|
165
244
|
telemetry.currentSector = position.currentSector;
|
|
166
245
|
telemetry.currentSectorProgress = position.currentSectorProgress;
|
|
246
|
+
clearFutureSectorTelemetry(telemetry);
|
|
247
|
+
telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
|
|
167
248
|
telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
|
|
168
249
|
telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
|
|
169
250
|
telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
|
|
251
|
+
syncLiveSectorTelemetry(telemetry, track);
|
|
170
252
|
}
|
|
171
253
|
|
|
172
254
|
function classifySectorPerformance(value, personalBest, overallBest) {
|
|
@@ -257,14 +339,13 @@ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, total
|
|
|
257
339
|
}
|
|
258
340
|
|
|
259
341
|
function createCar(driver, index, random, track, { standingStart = false } = {}) {
|
|
260
|
-
const gridDistance
|
|
342
|
+
const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart });
|
|
261
343
|
const start = pointAt(track, gridDistance);
|
|
262
|
-
const
|
|
263
|
-
const position = offsetTrackPoint(start, offset);
|
|
344
|
+
const position = offsetTrackPoint(start, gridOffset);
|
|
264
345
|
const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
|
|
265
346
|
const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
|
|
266
347
|
const personality = buildDriverPersonality(driver, index, racecraft, random);
|
|
267
|
-
const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
|
|
348
|
+
const launchSpeed = kphToSimSpeed(Math.max(70, 84 - index * 0.55 + pace * 4));
|
|
268
349
|
const vehicle = driver.vehicle ?? {};
|
|
269
350
|
|
|
270
351
|
return {
|
|
@@ -307,8 +388,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
307
388
|
aggression: personality.baseAggression,
|
|
308
389
|
gridLocked: standingStart,
|
|
309
390
|
gridDistance,
|
|
310
|
-
gridOffset
|
|
311
|
-
desiredOffset:
|
|
391
|
+
gridOffset,
|
|
392
|
+
desiredOffset: gridOffset,
|
|
312
393
|
progress: start.distance,
|
|
313
394
|
raceDistance: gridDistance,
|
|
314
395
|
lap: 1,
|
|
@@ -317,6 +398,9 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
317
398
|
gapAheadSeconds: Infinity,
|
|
318
399
|
leaderGapSeconds: 0,
|
|
319
400
|
intervalAheadSeconds: Infinity,
|
|
401
|
+
gapAheadLaps: 0,
|
|
402
|
+
intervalAheadLaps: 0,
|
|
403
|
+
leaderGapLaps: 0,
|
|
320
404
|
drsEligible: false,
|
|
321
405
|
drsActive: false,
|
|
322
406
|
drsZoneId: null,
|
|
@@ -328,6 +412,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
328
412
|
trackState: start,
|
|
329
413
|
contactCooldown: 0,
|
|
330
414
|
tireEnergy: 100,
|
|
415
|
+
usedTireCompounds: [driver.tire ?? ['M', 'H', 'S'][index % 3]],
|
|
416
|
+
automaticPitIntentEnabled: true,
|
|
331
417
|
};
|
|
332
418
|
}
|
|
333
419
|
|
|
@@ -337,82 +423,59 @@ function normalizeTotalLaps(value) {
|
|
|
337
423
|
return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
|
|
338
424
|
}
|
|
339
425
|
|
|
340
|
-
function
|
|
341
|
-
|
|
342
|
-
let max = -Infinity;
|
|
343
|
-
points.forEach((point) => {
|
|
344
|
-
const projection = point.x * axis.x + point.y * axis.y;
|
|
345
|
-
min = Math.min(min, projection);
|
|
346
|
-
max = Math.max(max, projection);
|
|
347
|
-
});
|
|
348
|
-
return { min, max };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function overlapOnAxis(a, b, axis) {
|
|
352
|
-
const first = projectOntoAxis(a, axis);
|
|
353
|
-
const second = projectOntoAxis(b, axis);
|
|
354
|
-
return Math.min(first.max, second.max) - Math.max(first.min, second.min);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function detectObbCollision(a, b) {
|
|
358
|
-
const aCorners = getCarCorners(a);
|
|
359
|
-
const bCorners = getCarCorners(b);
|
|
360
|
-
const axes = [
|
|
361
|
-
normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
|
|
362
|
-
normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
|
|
363
|
-
normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
|
|
364
|
-
normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
|
|
365
|
-
];
|
|
366
|
-
|
|
367
|
-
let minimumOverlap = Infinity;
|
|
368
|
-
let minimumAxis = null;
|
|
369
|
-
|
|
370
|
-
for (const axis of axes) {
|
|
371
|
-
const overlap = overlapOnAxis(aCorners, bCorners, axis);
|
|
372
|
-
if (overlap <= 0) return null;
|
|
373
|
-
if (overlap < minimumOverlap) {
|
|
374
|
-
minimumOverlap = overlap;
|
|
375
|
-
minimumAxis = axis;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const direction = { x: b.x - a.x, y: b.y - a.y };
|
|
380
|
-
if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
|
|
381
|
-
minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return { axis: minimumAxis, depth: minimumOverlap };
|
|
426
|
+
function forwardVector(car) {
|
|
427
|
+
return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
|
|
385
428
|
}
|
|
386
429
|
|
|
387
|
-
function
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
|
|
392
|
-
const direction = { x: b.x - a.x, y: b.y - a.y };
|
|
393
|
-
const physicalLongitudinalGap = dot(direction, axis);
|
|
394
|
-
if (physicalLongitudinalGap < 0) axis = { x: -axis.x, y: -axis.y };
|
|
430
|
+
function velocityVector(car) {
|
|
431
|
+
const forward = forwardVector(car);
|
|
432
|
+
return { x: forward.x * car.speed, y: forward.y * car.speed };
|
|
433
|
+
}
|
|
395
434
|
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
435
|
+
function createCollisionStewardContext(first, second, collision) {
|
|
436
|
+
const distanceDelta = collision.trackLength
|
|
437
|
+
? progressDelta(second.progress ?? second.raceDistance ?? 0, first.progress ?? first.raceDistance ?? 0, collision.trackLength)
|
|
438
|
+
: (second.raceDistance ?? 0) - (first.raceDistance ?? 0);
|
|
439
|
+
const sideBySideTolerance = VEHICLE_LIMITS.carLength * 0.18;
|
|
440
|
+
if (Math.abs(distanceDelta) <= sideBySideTolerance) {
|
|
441
|
+
const firstVelocity = velocityVector(first);
|
|
442
|
+
const secondVelocity = velocityVector(second);
|
|
443
|
+
const relativeVelocity = {
|
|
444
|
+
x: firstVelocity.x - secondVelocity.x,
|
|
445
|
+
y: firstVelocity.y - secondVelocity.y,
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
...collision,
|
|
449
|
+
impactSpeed: Math.hypot(relativeVelocity.x, relativeVelocity.y),
|
|
450
|
+
aheadDriverId: null,
|
|
451
|
+
atFaultDriverId: null,
|
|
452
|
+
sharedFault: true,
|
|
453
|
+
sharedFaultDriverIds: [first.id, second.id],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
399
456
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
457
|
+
const firstBehind = distanceDelta > 0;
|
|
458
|
+
const behind = firstBehind ? first : second;
|
|
459
|
+
const ahead = firstBehind ? second : first;
|
|
460
|
+
const directionBehindToAhead = normalizeVector({
|
|
461
|
+
x: ahead.x - behind.x,
|
|
462
|
+
y: ahead.y - behind.y,
|
|
463
|
+
});
|
|
464
|
+
const behindVelocity = velocityVector(behind);
|
|
465
|
+
const aheadVelocity = velocityVector(ahead);
|
|
466
|
+
const relativeVelocity = {
|
|
467
|
+
x: behindVelocity.x - aheadVelocity.x,
|
|
468
|
+
y: behindVelocity.y - aheadVelocity.y,
|
|
469
|
+
};
|
|
404
470
|
|
|
405
471
|
return {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
472
|
+
...collision,
|
|
473
|
+
impactSpeed: Math.max(0, dot(relativeVelocity, directionBehindToAhead)),
|
|
474
|
+
aheadDriverId: ahead.id,
|
|
475
|
+
atFaultDriverId: behind.id,
|
|
409
476
|
};
|
|
410
477
|
}
|
|
411
478
|
|
|
412
|
-
function forwardVector(car) {
|
|
413
|
-
return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
479
|
function dot(a, b) {
|
|
417
480
|
return a.x * b.x + a.y * b.y;
|
|
418
481
|
}
|
|
@@ -477,9 +540,12 @@ function interpolateTimeAtDistance(history, targetDistance) {
|
|
|
477
540
|
return null;
|
|
478
541
|
}
|
|
479
542
|
|
|
480
|
-
function estimateGapAheadSeconds(ahead, car, currentTime) {
|
|
543
|
+
function estimateGapAheadSeconds(ahead, car, currentTime, track) {
|
|
481
544
|
if (!ahead) return Infinity;
|
|
482
545
|
|
|
546
|
+
const timingLineGap = estimateTimingLineGapSeconds(ahead, car, currentTime, track);
|
|
547
|
+
if (Number.isFinite(timingLineGap)) return timingLineGap;
|
|
548
|
+
|
|
483
549
|
const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
|
|
484
550
|
if (Number.isFinite(crossingTime)) {
|
|
485
551
|
return Math.max(0, currentTime - crossingTime);
|
|
@@ -489,6 +555,106 @@ function estimateGapAheadSeconds(ahead, car, currentTime) {
|
|
|
489
555
|
return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
|
|
490
556
|
}
|
|
491
557
|
|
|
558
|
+
function wholeLapGap(aheadDistance, carDistance, trackLength) {
|
|
559
|
+
if (!Number.isFinite(aheadDistance) || !Number.isFinite(carDistance)) return 0;
|
|
560
|
+
if (!Number.isFinite(trackLength) || trackLength <= 0) return 0;
|
|
561
|
+
return Math.max(0, Math.floor((aheadDistance - carDistance + 1e-6) / trackLength));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function createTimingLines(track) {
|
|
565
|
+
const targetSpacing = metersToSimUnits(TIMING_LINE_TARGET_SPACING_METERS);
|
|
566
|
+
const count = Math.max(1, Math.round(track.length / targetSpacing));
|
|
567
|
+
const spacing = track.length / count;
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
spacing,
|
|
571
|
+
spacingMeters: simUnitsToMeters(spacing),
|
|
572
|
+
count,
|
|
573
|
+
lines: Array.from({ length: count }, (_, index) => {
|
|
574
|
+
const distance = index * spacing;
|
|
575
|
+
const point = pointAt(track, distance);
|
|
576
|
+
return {
|
|
577
|
+
index,
|
|
578
|
+
distance,
|
|
579
|
+
distanceMeters: simUnitsToMeters(distance),
|
|
580
|
+
x: point.x,
|
|
581
|
+
y: point.y,
|
|
582
|
+
heading: point.heading,
|
|
583
|
+
};
|
|
584
|
+
}),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function getTimingLineNumber(track, raceDistance) {
|
|
589
|
+
const spacing = track.timingLines?.spacing;
|
|
590
|
+
if (!Number.isFinite(spacing) || spacing <= 0 || !Number.isFinite(raceDistance)) return null;
|
|
591
|
+
return Math.floor(Math.max(0, raceDistance) / spacing);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function resetTimingLineCrossings(car, currentTime) {
|
|
595
|
+
car.timingLineCrossings = Object.create(null);
|
|
596
|
+
car.timingLineLastUpdatedAt = currentTime;
|
|
597
|
+
car.previousRaceDistanceForTiming = car.raceDistance;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function recordTimingLineCrossings(car, previousRaceDistance, currentTime, track) {
|
|
601
|
+
const spacing = track.timingLines?.spacing;
|
|
602
|
+
if (!Number.isFinite(spacing) || spacing <= 0 || !Number.isFinite(car.raceDistance)) return;
|
|
603
|
+
if (!car.timingLineCrossings || typeof car.timingLineCrossings !== 'object') {
|
|
604
|
+
resetTimingLineCrossings(car, currentTime);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const previousDistance = Number.isFinite(previousRaceDistance) ? previousRaceDistance : car.raceDistance;
|
|
609
|
+
const currentDistance = car.raceDistance;
|
|
610
|
+
const travelled = currentDistance - previousDistance;
|
|
611
|
+
const previousTime = Number.isFinite(car.timingLineLastUpdatedAt) ? car.timingLineLastUpdatedAt : currentTime;
|
|
612
|
+
const elapsed = Math.max(0, currentTime - previousTime);
|
|
613
|
+
|
|
614
|
+
if (travelled > 1e-6 && travelled < track.length / 2) {
|
|
615
|
+
const firstLine = Math.floor(Math.max(0, previousDistance) / spacing) + 1;
|
|
616
|
+
const lastLine = Math.floor(Math.max(0, currentDistance) / spacing);
|
|
617
|
+
|
|
618
|
+
for (let lineNumber = firstLine; lineNumber <= lastLine; lineNumber += 1) {
|
|
619
|
+
const lineDistance = lineNumber * spacing;
|
|
620
|
+
if (lineDistance <= previousDistance + 1e-6 || lineDistance > currentDistance + 1e-6) continue;
|
|
621
|
+
const ratio = clamp((lineDistance - previousDistance) / travelled, 0, 1);
|
|
622
|
+
car.timingLineCrossings[lineNumber] = previousTime + ratio * elapsed;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
car.timingLineLastUpdatedAt = currentTime;
|
|
627
|
+
const latestLine = getTimingLineNumber(track, currentDistance);
|
|
628
|
+
if (latestLine != null) {
|
|
629
|
+
const cutoff = latestLine - (track.timingLines.count * TIMING_LINE_HISTORY_LAPS);
|
|
630
|
+
Object.keys(car.timingLineCrossings).forEach((key) => {
|
|
631
|
+
if (Number(key) < cutoff) delete car.timingLineCrossings[key];
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function getTimingLineCrossingTime(car, lineNumber) {
|
|
637
|
+
const value = car?.timingLineCrossings?.[lineNumber];
|
|
638
|
+
return Number.isFinite(value) ? value : null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function estimateTimingLineGapSeconds(ahead, car, currentTime, track) {
|
|
642
|
+
if (!ahead || !car) return Infinity;
|
|
643
|
+
const currentLine = getTimingLineNumber(track, Math.min(ahead.raceDistance ?? 0, car.raceDistance ?? 0));
|
|
644
|
+
if (currentLine == null) return null;
|
|
645
|
+
const oldestLine = Math.max(0, currentLine - (track.timingLines?.count ?? 0));
|
|
646
|
+
|
|
647
|
+
for (let lineNumber = currentLine; lineNumber >= oldestLine; lineNumber -= 1) {
|
|
648
|
+
const aheadTime = getTimingLineCrossingTime(ahead, lineNumber);
|
|
649
|
+
const carTime = getTimingLineCrossingTime(car, lineNumber);
|
|
650
|
+
if (Number.isFinite(aheadTime) && Number.isFinite(carTime)) {
|
|
651
|
+
return Math.max(0, carTime - aheadTime);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
492
658
|
function recordDrsDetection(car, zoneId, currentTime) {
|
|
493
659
|
const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
|
|
494
660
|
const next = { passage: previous.passage + 1, time: currentTime };
|
|
@@ -499,7 +665,403 @@ function recordDrsDetection(car, zoneId, currentTime) {
|
|
|
499
665
|
return next;
|
|
500
666
|
}
|
|
501
667
|
|
|
502
|
-
function
|
|
668
|
+
function clonePointLike(point) {
|
|
669
|
+
if (!point) return point;
|
|
670
|
+
return { ...point };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function clonePointArray(points) {
|
|
674
|
+
return Array.isArray(points) ? points.map(clonePointLike) : points;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function clonePitLaneModel(pitLane) {
|
|
678
|
+
if (!pitLane) return pitLane;
|
|
679
|
+
return {
|
|
680
|
+
...pitLane,
|
|
681
|
+
entry: pitLane.entry ? {
|
|
682
|
+
...pitLane.entry,
|
|
683
|
+
trackPoint: clonePointLike(pitLane.entry.trackPoint),
|
|
684
|
+
edgePoint: clonePointLike(pitLane.entry.edgePoint),
|
|
685
|
+
trackConnectPoint: clonePointLike(pitLane.entry.trackConnectPoint),
|
|
686
|
+
lanePoint: clonePointLike(pitLane.entry.lanePoint),
|
|
687
|
+
roadCenterline: clonePointArray(pitLane.entry.roadCenterline),
|
|
688
|
+
connector: clonePointArray(pitLane.entry.connector),
|
|
689
|
+
} : pitLane.entry,
|
|
690
|
+
exit: pitLane.exit ? {
|
|
691
|
+
...pitLane.exit,
|
|
692
|
+
trackPoint: clonePointLike(pitLane.exit.trackPoint),
|
|
693
|
+
edgePoint: clonePointLike(pitLane.exit.edgePoint),
|
|
694
|
+
trackConnectPoint: clonePointLike(pitLane.exit.trackConnectPoint),
|
|
695
|
+
lanePoint: clonePointLike(pitLane.exit.lanePoint),
|
|
696
|
+
roadCenterline: clonePointArray(pitLane.exit.roadCenterline),
|
|
697
|
+
connector: clonePointArray(pitLane.exit.connector),
|
|
698
|
+
} : pitLane.exit,
|
|
699
|
+
mainLane: pitLane.mainLane ? {
|
|
700
|
+
...pitLane.mainLane,
|
|
701
|
+
start: clonePointLike(pitLane.mainLane.start),
|
|
702
|
+
end: clonePointLike(pitLane.mainLane.end),
|
|
703
|
+
points: clonePointArray(pitLane.mainLane.points),
|
|
704
|
+
} : pitLane.mainLane,
|
|
705
|
+
workingLane: pitLane.workingLane ? {
|
|
706
|
+
...pitLane.workingLane,
|
|
707
|
+
start: clonePointLike(pitLane.workingLane.start),
|
|
708
|
+
end: clonePointLike(pitLane.workingLane.end),
|
|
709
|
+
points: clonePointArray(pitLane.workingLane.points),
|
|
710
|
+
} : pitLane.workingLane,
|
|
711
|
+
fastLane: pitLane.fastLane ? { ...pitLane.fastLane } : pitLane.fastLane,
|
|
712
|
+
serviceNormal: clonePointLike(pitLane.serviceNormal),
|
|
713
|
+
boxes: Array.isArray(pitLane.boxes)
|
|
714
|
+
? pitLane.boxes.map((box) => ({
|
|
715
|
+
...box,
|
|
716
|
+
laneTarget: clonePointLike(box.laneTarget),
|
|
717
|
+
center: clonePointLike(box.center),
|
|
718
|
+
corners: clonePointArray(box.corners),
|
|
719
|
+
}))
|
|
720
|
+
: pitLane.boxes,
|
|
721
|
+
serviceAreas: Array.isArray(pitLane.serviceAreas)
|
|
722
|
+
? pitLane.serviceAreas.map((area) => ({
|
|
723
|
+
...area,
|
|
724
|
+
laneTarget: clonePointLike(area.laneTarget),
|
|
725
|
+
center: clonePointLike(area.center),
|
|
726
|
+
queuePoint: clonePointLike(area.queuePoint),
|
|
727
|
+
corners: clonePointArray(area.corners),
|
|
728
|
+
queueCorners: clonePointArray(area.queueCorners),
|
|
729
|
+
garageBoxIds: [...(area.garageBoxIds ?? [])],
|
|
730
|
+
pitCrew: area.pitCrew ? { ...area.pitCrew } : area.pitCrew,
|
|
731
|
+
}))
|
|
732
|
+
: pitLane.serviceAreas,
|
|
733
|
+
teams: Array.isArray(pitLane.teams)
|
|
734
|
+
? pitLane.teams.map((team) => ({ ...team, boxIds: [...(team.boxIds ?? [])], pitCrew: team.pitCrew ? { ...team.pitCrew } : team.pitCrew }))
|
|
735
|
+
: pitLane.teams,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function routePoint(point, heading = point?.heading, options = {}) {
|
|
740
|
+
if (!point) return null;
|
|
741
|
+
return {
|
|
742
|
+
x: point.x,
|
|
743
|
+
y: point.y,
|
|
744
|
+
heading,
|
|
745
|
+
limiterActive: Boolean(options.limiterActive ?? point.limiterActive),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function offsetPitLanePoint(pitLane, point, lateralOffset) {
|
|
750
|
+
return {
|
|
751
|
+
...point,
|
|
752
|
+
x: point.x + pitLane.serviceNormal.x * lateralOffset,
|
|
753
|
+
y: point.y + pitLane.serviceNormal.y * lateralOffset,
|
|
754
|
+
heading: pitLane.mainLane.heading,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function pitMainLanePointAt(pitLane, distanceAlongLane, lateralOffset = 0) {
|
|
759
|
+
const amount = pitLane.mainLane.length > 0
|
|
760
|
+
? clamp(distanceAlongLane / pitLane.mainLane.length, 0, 1)
|
|
761
|
+
: 0;
|
|
762
|
+
const point = {
|
|
763
|
+
x: pitLane.mainLane.start.x + (pitLane.mainLane.end.x - pitLane.mainLane.start.x) * amount,
|
|
764
|
+
y: pitLane.mainLane.start.y + (pitLane.mainLane.end.y - pitLane.mainLane.start.y) * amount,
|
|
765
|
+
heading: pitLane.mainLane.heading,
|
|
766
|
+
};
|
|
767
|
+
return offsetPitLanePoint(pitLane, point, lateralOffset);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function pitDriveLaneOffset(pitLane) {
|
|
771
|
+
return pitLane.fastLane?.offset ?? 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function sameRoutePoint(first, second) {
|
|
775
|
+
return first && second && Math.hypot(first.x - second.x, first.y - second.y) < 0.001;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function shiftPreviousRenderPose(car, dx, dy, dheading = 0) {
|
|
779
|
+
car.previousX = Number.isFinite(car.previousX) ? car.previousX + dx : car.x + dx;
|
|
780
|
+
car.previousY = Number.isFinite(car.previousY) ? car.previousY + dy : car.y + dy;
|
|
781
|
+
car.previousHeading = normalizeAngle(
|
|
782
|
+
(Number.isFinite(car.previousHeading) ? car.previousHeading : car.heading) + dheading,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function pointDistance(first, second) {
|
|
787
|
+
if (!first || !second) return Infinity;
|
|
788
|
+
return Math.hypot(first.x - second.x, first.y - second.y);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function createRoute(points) {
|
|
792
|
+
const routePoints = points
|
|
793
|
+
.filter(Boolean)
|
|
794
|
+
.map((point) => routePoint(point))
|
|
795
|
+
.filter(Boolean)
|
|
796
|
+
.reduce((deduped, point) => {
|
|
797
|
+
if (!sameRoutePoint(deduped.at(-1), point)) {
|
|
798
|
+
deduped.push(point);
|
|
799
|
+
} else if (point.limiterActive) {
|
|
800
|
+
deduped.at(-1).limiterActive = true;
|
|
801
|
+
}
|
|
802
|
+
return deduped;
|
|
803
|
+
}, []);
|
|
804
|
+
const segments = [];
|
|
805
|
+
let totalLength = 0;
|
|
806
|
+
|
|
807
|
+
for (let index = 0; index < routePoints.length - 1; index += 1) {
|
|
808
|
+
const start = routePoints[index];
|
|
809
|
+
const end = routePoints[index + 1];
|
|
810
|
+
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
|
811
|
+
if (length <= 0.001) continue;
|
|
812
|
+
const heading = Math.atan2(end.y - start.y, end.x - start.x);
|
|
813
|
+
segments.push({
|
|
814
|
+
start,
|
|
815
|
+
end,
|
|
816
|
+
length,
|
|
817
|
+
heading,
|
|
818
|
+
startDistance: totalLength,
|
|
819
|
+
endDistance: totalLength + length,
|
|
820
|
+
limiterActive: Boolean(start.limiterActive && end.limiterActive),
|
|
821
|
+
});
|
|
822
|
+
totalLength += length;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let nextLimiterStartDistance = Infinity;
|
|
826
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
827
|
+
const segment = segments[index];
|
|
828
|
+
if (segment.limiterActive) nextLimiterStartDistance = segment.startDistance;
|
|
829
|
+
segment.nextLimiterStartDistance = nextLimiterStartDistance;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
points: routePoints,
|
|
834
|
+
segments,
|
|
835
|
+
length: totalLength,
|
|
836
|
+
segmentCursor: 0,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function findRouteSegment(route, distanceAlong, endPadding = 0) {
|
|
841
|
+
if (!route?.segments?.length) return null;
|
|
842
|
+
const clampedDistance = clamp(distanceAlong, 0, route.length);
|
|
843
|
+
const segments = route.segments;
|
|
844
|
+
let index = clamp(Math.floor(route.segmentCursor ?? 0), 0, segments.length - 1);
|
|
845
|
+
let segment = segments[index];
|
|
846
|
+
|
|
847
|
+
if (
|
|
848
|
+
clampedDistance >= segment.startDistance - 0.001 &&
|
|
849
|
+
clampedDistance <= segment.endDistance - endPadding
|
|
850
|
+
) {
|
|
851
|
+
return segment;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (clampedDistance > segment.endDistance - endPadding) {
|
|
855
|
+
while (index < segments.length - 1 && clampedDistance > segments[index].endDistance - endPadding) {
|
|
856
|
+
index += 1;
|
|
857
|
+
}
|
|
858
|
+
} else {
|
|
859
|
+
while (index > 0 && clampedDistance < segments[index].startDistance - 0.001) {
|
|
860
|
+
index -= 1;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
segment = segments[index];
|
|
865
|
+
if (
|
|
866
|
+
clampedDistance < segment.startDistance - 0.001 ||
|
|
867
|
+
clampedDistance > segment.endDistance - endPadding
|
|
868
|
+
) {
|
|
869
|
+
segment = segments.find((candidate) => (
|
|
870
|
+
clampedDistance >= candidate.startDistance - 0.001 &&
|
|
871
|
+
clampedDistance <= candidate.endDistance - endPadding
|
|
872
|
+
)) ?? segments.at(-1);
|
|
873
|
+
index = segments.indexOf(segment);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
route.segmentCursor = Math.max(0, index);
|
|
877
|
+
return segment;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function sampleRoute(route, distanceAlong) {
|
|
881
|
+
if (!route?.segments?.length) return route?.points?.[0] ?? null;
|
|
882
|
+
const clampedDistance = clamp(distanceAlong, 0, route.length);
|
|
883
|
+
const segment = findRouteSegment(route, clampedDistance) ?? route.segments.at(-1);
|
|
884
|
+
const amount = clamp((clampedDistance - segment.startDistance) / segment.length, 0, 1);
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
x: segment.start.x + (segment.end.x - segment.start.x) * amount,
|
|
888
|
+
y: segment.start.y + (segment.end.y - segment.start.y) * amount,
|
|
889
|
+
heading: segment.heading,
|
|
890
|
+
limiterActive: segment.limiterActive,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function routeLimiterActiveAt(route, distanceAlong) {
|
|
895
|
+
if (!route?.segments?.length) return false;
|
|
896
|
+
const clampedDistance = clamp(distanceAlong, 0, route.length);
|
|
897
|
+
const segment = findRouteSegment(route, clampedDistance, 0.001) ?? route.segments.at(-1);
|
|
898
|
+
return Boolean(segment?.limiterActive);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function distanceToNextLimiterSegment(route, distanceAlong) {
|
|
902
|
+
if (!route?.segments?.length) return Infinity;
|
|
903
|
+
const clampedDistance = clamp(distanceAlong, 0, route.length);
|
|
904
|
+
const segment = findRouteSegment(route, clampedDistance, 0.001);
|
|
905
|
+
if (!segment) return Infinity;
|
|
906
|
+
if (segment.limiterActive && segment.endDistance > clampedDistance + 0.001) return 0;
|
|
907
|
+
return Math.max(0, segment.nextLimiterStartDistance - clampedDistance);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function easeInOut(value) {
|
|
911
|
+
const amount = clamp(value, 0, 1);
|
|
912
|
+
return amount * amount * (3 - 2 * amount);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function projectPositionToSegment(position, segment) {
|
|
916
|
+
const dx = segment.end.x - segment.start.x;
|
|
917
|
+
const dy = segment.end.y - segment.start.y;
|
|
918
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
919
|
+
const amount = lengthSquared > 0
|
|
920
|
+
? clamp(((position.x - segment.start.x) * dx + (position.y - segment.start.y) * dy) / lengthSquared, 0, 1)
|
|
921
|
+
: 0;
|
|
922
|
+
const x = segment.start.x + dx * amount;
|
|
923
|
+
const y = segment.start.y + dy * amount;
|
|
924
|
+
const distanceSquared = (position.x - x) ** 2 + (position.y - y) ** 2;
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
distanceAlong: segment.startDistance + segment.length * amount,
|
|
928
|
+
distanceSquared,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function nearestDistanceOnRoute(route, position, previousDistance = 0) {
|
|
933
|
+
if (!route?.segments?.length) return 0;
|
|
934
|
+
let best = null;
|
|
935
|
+
const backtrackAllowance = metersToSimUnits(90);
|
|
936
|
+
|
|
937
|
+
route.segments.forEach((segment) => {
|
|
938
|
+
if (segment.endDistance < previousDistance - backtrackAllowance) return;
|
|
939
|
+
const projected = projectPositionToSegment(position, segment);
|
|
940
|
+
if (!best || projected.distanceSquared < best.distanceSquared) best = projected;
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
return clamp(best?.distanceAlong ?? previousDistance, 0, route.length);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function createPitApproachPoints(track, car, pitLane, entryRaceDistance) {
|
|
947
|
+
const currentDistance = car.raceDistance ?? entryRaceDistance - PIT_ENTRY_APPROACH_DISTANCE;
|
|
948
|
+
const remaining = Math.max(0, entryRaceDistance - currentDistance);
|
|
949
|
+
const currentState = nearestTrackState(track, car, car.progress);
|
|
950
|
+
const startOffset = currentState.inPitLane ? 0 : currentState.signedOffset ?? 0;
|
|
951
|
+
const entryConnectState = nearestTrackState(track, pitLane.entry.trackConnectPoint, pitLane.entry.trackDistance);
|
|
952
|
+
const targetOffset = entryConnectState.signedOffset ?? 0;
|
|
953
|
+
const points = [routePoint(car, car.heading)];
|
|
954
|
+
const straightDistance = remaining > metersToSimUnits(14)
|
|
955
|
+
? Math.min(remaining * 0.22, metersToSimUnits(42))
|
|
956
|
+
: 0;
|
|
957
|
+
if (straightDistance > metersToSimUnits(1)) {
|
|
958
|
+
points.push(routePoint({
|
|
959
|
+
x: car.x + Math.cos(car.heading) * straightDistance,
|
|
960
|
+
y: car.y + Math.sin(car.heading) * straightDistance,
|
|
961
|
+
}, car.heading));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const transitionDistance = Math.max(0, remaining - straightDistance);
|
|
965
|
+
const steps = Math.max(4, Math.ceil(Math.max(transitionDistance, PIT_ENTRY_APPROACH_DISTANCE * 0.45) / metersToSimUnits(74)));
|
|
966
|
+
for (let index = 1; index <= steps; index += 1) {
|
|
967
|
+
const amount = index / steps;
|
|
968
|
+
const distance = currentDistance + straightDistance + transitionDistance * amount;
|
|
969
|
+
const trackPoint = pointAt(track, distance);
|
|
970
|
+
const lateralAmount = easeInOut(amount);
|
|
971
|
+
const offset = startOffset + (targetOffset - startOffset) * lateralAmount;
|
|
972
|
+
const point = offsetTrackPoint(trackPoint, offset);
|
|
973
|
+
points.push(routePoint(point, trackPoint.heading));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
points.push(routePoint(pitLane.entry.trackConnectPoint, pitLane.entry.trackPoint?.heading));
|
|
977
|
+
return points;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function firstDifferentCompound(currentTire, compounds) {
|
|
981
|
+
const available = Array.isArray(compounds) && compounds.length ? compounds : ['S', 'M', 'H'];
|
|
982
|
+
return available.find((compound) => compound !== currentTire) ?? currentTire;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function normalizePitCompound(value, compounds) {
|
|
986
|
+
if (value == null || value === '') return null;
|
|
987
|
+
const available = Array.isArray(compounds) && compounds.length ? compounds : ['S', 'M', 'H'];
|
|
988
|
+
return available.includes(value) ? value : null;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function normalizePitIntent(value) {
|
|
992
|
+
const number = Number(value);
|
|
993
|
+
if (!Number.isInteger(number) || number < PIT_INTENT_NONE || number > PIT_INTENT_COMMITTED) return null;
|
|
994
|
+
return number;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function normalizePitCrewStats(value) {
|
|
998
|
+
const source = value && typeof value === 'object' ? value : {};
|
|
999
|
+
const rating = (entry) => {
|
|
1000
|
+
const numeric = Number(entry);
|
|
1001
|
+
return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : 0.5;
|
|
1002
|
+
};
|
|
1003
|
+
return {
|
|
1004
|
+
speed: rating(source.speed ?? source.pace),
|
|
1005
|
+
consistency: rating(source.consistency),
|
|
1006
|
+
reliability: rating(source.reliability),
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function pitLaneStatusSnapshot(raceControl, pitLane, pitStops) {
|
|
1011
|
+
const enabled = Boolean(pitLane?.enabled && pitStops?.enabled);
|
|
1012
|
+
const redFlag = Boolean(raceControl.redFlag);
|
|
1013
|
+
const open = enabled && Boolean(raceControl.pitLaneOpen) && !redFlag;
|
|
1014
|
+
return {
|
|
1015
|
+
enabled,
|
|
1016
|
+
open,
|
|
1017
|
+
reason: !enabled ? 'unavailable' : redFlag ? 'red-flag' : open ? 'open' : 'closed',
|
|
1018
|
+
color: open ? 'green' : redFlag ? 'yellow' : 'red',
|
|
1019
|
+
light: open ? '#22c55e' : redFlag ? '#facc15' : '#ef4444',
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function serializePitStop(pitStop) {
|
|
1024
|
+
if (!pitStop) return null;
|
|
1025
|
+
return {
|
|
1026
|
+
status: pitStop.status,
|
|
1027
|
+
intent: normalizePitIntent(pitStop.intent) ?? PIT_INTENT_NONE,
|
|
1028
|
+
phase: pitStop.phase ?? null,
|
|
1029
|
+
boxIndex: pitStop.boxIndex,
|
|
1030
|
+
boxId: pitStop.boxId,
|
|
1031
|
+
garageBoxIndex: pitStop.garageBoxIndex ?? null,
|
|
1032
|
+
garageBoxId: pitStop.garageBoxId ?? null,
|
|
1033
|
+
teamId: pitStop.teamId ?? null,
|
|
1034
|
+
teamColor: pitStop.teamColor ?? null,
|
|
1035
|
+
stopsCompleted: pitStop.stopsCompleted ?? 0,
|
|
1036
|
+
queueingForService: Boolean(pitStop.queueingForService),
|
|
1037
|
+
plannedRaceDistance: finiteOrNull(pitStop.plannedRaceDistance),
|
|
1038
|
+
entryRaceDistance: finiteOrNull(pitStop.entryRaceDistance),
|
|
1039
|
+
serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
|
|
1040
|
+
penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
|
|
1041
|
+
penaltyServiceTotalSeconds: finiteOrNull(pitStop.penaltyServiceTotal),
|
|
1042
|
+
servingPenaltyIds: [...(pitStop.servingPenaltyIds ?? [])],
|
|
1043
|
+
targetTire: pitStop.targetTire ?? null,
|
|
1044
|
+
serviceProfile: pitStop.serviceProfile ? { ...pitStop.serviceProfile } : null,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function serializeWheels(wheels = []) {
|
|
1049
|
+
return wheels.map((wheel) => ({
|
|
1050
|
+
id: wheel.id,
|
|
1051
|
+
x: wheel.x,
|
|
1052
|
+
y: wheel.y,
|
|
1053
|
+
signedOffset: wheel.signedOffset,
|
|
1054
|
+
crossTrackError: wheel.crossTrackError,
|
|
1055
|
+
surface: wheel.surface,
|
|
1056
|
+
onTrack: Boolean(wheel.onTrack),
|
|
1057
|
+
inPitLane: Boolean(wheel.inPitLane),
|
|
1058
|
+
fullyOutsideWhiteLine: Boolean(wheel.fullyOutsideWhiteLine),
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function serializeCar(car, rank, penaltySeconds = 0) {
|
|
1063
|
+
const finishTime = car.finishTime ?? null;
|
|
1064
|
+
const raceStatus = car.finished ? 'waved-flag' : 'racing';
|
|
503
1065
|
return {
|
|
504
1066
|
id: car.id,
|
|
505
1067
|
code: car.code,
|
|
@@ -531,8 +1093,14 @@ function serializeCar(car, rank) {
|
|
|
531
1093
|
},
|
|
532
1094
|
rank,
|
|
533
1095
|
classifiedRank: car.classifiedRank ?? rank,
|
|
1096
|
+
finishRank: car.finishRank ?? null,
|
|
1097
|
+
status: raceStatus,
|
|
1098
|
+
raceStatus,
|
|
1099
|
+
wavedFlag: Boolean(car.finished),
|
|
534
1100
|
finished: Boolean(car.finished),
|
|
535
|
-
finishTime
|
|
1101
|
+
finishTime,
|
|
1102
|
+
penaltySeconds,
|
|
1103
|
+
adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
|
|
536
1104
|
previousX: car.previousX ?? car.x,
|
|
537
1105
|
previousY: car.previousY ?? car.y,
|
|
538
1106
|
x: car.x,
|
|
@@ -557,6 +1125,9 @@ function serializeCar(car, rank) {
|
|
|
557
1125
|
gapAheadSeconds: car.gapAheadSeconds,
|
|
558
1126
|
intervalAheadSeconds: car.intervalAheadSeconds,
|
|
559
1127
|
leaderGapSeconds: car.leaderGapSeconds,
|
|
1128
|
+
gapAheadLaps: car.gapAheadLaps ?? 0,
|
|
1129
|
+
intervalAheadLaps: car.intervalAheadLaps ?? car.gapAheadLaps ?? 0,
|
|
1130
|
+
leaderGapLaps: car.leaderGapLaps ?? 0,
|
|
560
1131
|
drsEligible: car.drsEligible,
|
|
561
1132
|
drsActive: car.drsActive,
|
|
562
1133
|
drsZoneId: car.drsZoneId,
|
|
@@ -565,27 +1136,161 @@ function serializeCar(car, rank) {
|
|
|
565
1136
|
signedOffset: car.trackState?.signedOffset ?? 0,
|
|
566
1137
|
crossTrackError: car.trackState?.crossTrackError ?? 0,
|
|
567
1138
|
surface: car.trackState?.surface ?? 'track',
|
|
1139
|
+
wheels: serializeWheels(car.wheelStates),
|
|
1140
|
+
inPitLane: Boolean(car.trackState?.inPitLane),
|
|
1141
|
+
pitLanePart: car.trackState?.pitLanePart ?? null,
|
|
1142
|
+
pitBoxId: car.trackState?.pitBoxId ?? null,
|
|
1143
|
+
pitLaneCrossTrackError: car.trackState?.pitLaneCrossTrackError ?? null,
|
|
568
1144
|
contactCooldown: car.contactCooldown,
|
|
569
1145
|
tireEnergy: car.tireEnergy,
|
|
1146
|
+
usedTireCompounds: [...(car.usedTireCompounds ?? [])],
|
|
1147
|
+
pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
|
|
1148
|
+
pitStop: serializePitStop(car.pitStop),
|
|
570
1149
|
positionSource: 'integrated-vehicle',
|
|
571
1150
|
};
|
|
572
1151
|
}
|
|
573
1152
|
|
|
1153
|
+
function serializeRenderPitStop(pitStop) {
|
|
1154
|
+
if (!pitStop) return null;
|
|
1155
|
+
return {
|
|
1156
|
+
phase: pitStop.phase ?? null,
|
|
1157
|
+
serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
|
|
1158
|
+
penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function serializeRenderCar(car) {
|
|
1163
|
+
return {
|
|
1164
|
+
id: car.id,
|
|
1165
|
+
color: car.color,
|
|
1166
|
+
previousX: car.previousX ?? car.x,
|
|
1167
|
+
previousY: car.previousY ?? car.y,
|
|
1168
|
+
x: car.x,
|
|
1169
|
+
y: car.y,
|
|
1170
|
+
previousHeading: car.previousHeading ?? car.heading,
|
|
1171
|
+
heading: car.heading,
|
|
1172
|
+
drsActive: Boolean(car.drsActive),
|
|
1173
|
+
pitStop: serializeRenderPitStop(car.pitStop),
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function serializeObservationPitStop(pitStop) {
|
|
1178
|
+
if (!pitStop) return null;
|
|
1179
|
+
return {
|
|
1180
|
+
status: pitStop.status,
|
|
1181
|
+
intent: normalizePitIntent(pitStop.intent) ?? PIT_INTENT_NONE,
|
|
1182
|
+
phase: pitStop.phase ?? null,
|
|
1183
|
+
targetTire: pitStop.targetTire ?? null,
|
|
1184
|
+
serviceRemainingSeconds: finiteOrNull(pitStop.serviceRemaining),
|
|
1185
|
+
penaltyServiceRemainingSeconds: finiteOrNull(pitStop.penaltyServiceRemaining),
|
|
1186
|
+
stopsCompleted: pitStop.stopsCompleted ?? 0,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function serializeObservationCar(car, rank) {
|
|
1191
|
+
return {
|
|
1192
|
+
id: car.id,
|
|
1193
|
+
rank,
|
|
1194
|
+
previousX: car.previousX ?? car.x,
|
|
1195
|
+
previousY: car.previousY ?? car.y,
|
|
1196
|
+
x: car.x,
|
|
1197
|
+
y: car.y,
|
|
1198
|
+
previousHeading: car.previousHeading ?? car.heading,
|
|
1199
|
+
heading: car.heading,
|
|
1200
|
+
steeringAngle: car.steeringAngle,
|
|
1201
|
+
yawRate: car.yawRate,
|
|
1202
|
+
speed: car.speed,
|
|
1203
|
+
speedKph: simSpeedToKph(car.speed),
|
|
1204
|
+
throttle: car.throttle,
|
|
1205
|
+
brake: car.brake,
|
|
1206
|
+
progress: car.progress,
|
|
1207
|
+
raceDistance: car.raceDistance,
|
|
1208
|
+
lap: car.lap,
|
|
1209
|
+
lapTelemetry: serializeLapTelemetry(car.lapTelemetry ?? createLapTelemetry({ length: 1 }, 0, 0)),
|
|
1210
|
+
signedOffset: car.trackState?.signedOffset ?? 0,
|
|
1211
|
+
crossTrackError: car.trackState?.crossTrackError ?? 0,
|
|
1212
|
+
surface: car.trackState?.surface ?? 'track',
|
|
1213
|
+
trackHeadingError: car.trackHeadingError,
|
|
1214
|
+
trackState: car.trackState ? {
|
|
1215
|
+
distance: car.trackState.distance,
|
|
1216
|
+
signedOffset: car.trackState.signedOffset,
|
|
1217
|
+
crossTrackError: car.trackState.crossTrackError,
|
|
1218
|
+
surface: car.trackState.surface,
|
|
1219
|
+
inPitLane: Boolean(car.trackState.inPitLane),
|
|
1220
|
+
pitLanePart: car.trackState.pitLanePart ?? null,
|
|
1221
|
+
pitBoxId: car.trackState.pitBoxId ?? null,
|
|
1222
|
+
curvature: car.trackState.curvature ?? 0,
|
|
1223
|
+
} : null,
|
|
1224
|
+
wheels: serializeWheels(car.wheelStates),
|
|
1225
|
+
inPitLane: Boolean(car.trackState?.inPitLane),
|
|
1226
|
+
pitLanePart: car.trackState?.pitLanePart ?? null,
|
|
1227
|
+
pitBoxId: car.trackState?.pitBoxId ?? null,
|
|
1228
|
+
tireEnergy: car.tireEnergy ?? null,
|
|
1229
|
+
pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
|
|
1230
|
+
pitStop: serializeObservationPitStop(car.pitStop),
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function createEmptyPenaltyStats() {
|
|
1235
|
+
return {
|
|
1236
|
+
seconds: 0,
|
|
1237
|
+
positionDrop: 0,
|
|
1238
|
+
disqualified: false,
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function getPenaltyStats(statsByDriver, driverId) {
|
|
1243
|
+
return statsByDriver.get(driverId) ?? createEmptyPenaltyStats();
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function buildPenaltyStatsByDriver(penalties = []) {
|
|
1247
|
+
const byDriver = new Map();
|
|
1248
|
+
penalties.forEach((penalty) => {
|
|
1249
|
+
if (!penalty?.driverId || !isPenaltyActive(penalty)) return;
|
|
1250
|
+
const stats = byDriver.get(penalty.driverId) ?? createEmptyPenaltyStats();
|
|
1251
|
+
stats.seconds += Number(penalty.penaltySeconds) || 0;
|
|
1252
|
+
stats.positionDrop += Number(penalty.positionDrop) || 0;
|
|
1253
|
+
stats.disqualified = stats.disqualified || Boolean(penalty.disqualified);
|
|
1254
|
+
byDriver.set(penalty.driverId, stats);
|
|
1255
|
+
});
|
|
1256
|
+
return byDriver;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function isLegallyInsidePitLaneForTrackLimits(car) {
|
|
1260
|
+
if (!car.trackState?.inPitLane) return false;
|
|
1261
|
+
const wheels = car.wheelStates ?? [];
|
|
1262
|
+
return wheels.length > 0 && wheels.some((wheel) => wheel.inPitLane);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
574
1265
|
export class F1RaceSimulation {
|
|
575
1266
|
constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
|
|
576
1267
|
this.seed = seed;
|
|
577
1268
|
this.random = createMulberry32(seed);
|
|
578
1269
|
const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
|
|
579
|
-
|
|
1270
|
+
const builtTrack = buildTrackModel(trackDefinition);
|
|
1271
|
+
this.track = {
|
|
1272
|
+
...builtTrack,
|
|
1273
|
+
pitLane: clonePitLaneModel(builtTrack.pitLane),
|
|
1274
|
+
};
|
|
1275
|
+
this.track.timingLines = createTimingLines(this.track);
|
|
580
1276
|
this.trackSeed = this.track.seed ?? trackSeed;
|
|
581
|
-
this.rules =
|
|
1277
|
+
this.rules = normalizeRaceRules(rules);
|
|
582
1278
|
this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
|
|
583
1279
|
this.totalLaps = normalizeTotalLaps(totalLaps);
|
|
584
1280
|
this.time = 0;
|
|
585
1281
|
this.events = [];
|
|
1282
|
+
this.penalties = [];
|
|
1283
|
+
this.nextPenaltyId = 1;
|
|
1284
|
+
this.stewardState = {
|
|
1285
|
+
trackLimits: Object.create(null),
|
|
1286
|
+
pitLaneSpeeding: Object.create(null),
|
|
1287
|
+
tireRequirement: Object.create(null),
|
|
1288
|
+
};
|
|
586
1289
|
this.raceControl = {
|
|
587
1290
|
mode: this.rules.standingStart === false ? 'green' : 'pre-start',
|
|
588
1291
|
frozenOrder: null,
|
|
1292
|
+
redFlag: false,
|
|
1293
|
+
pitLaneOpen: true,
|
|
589
1294
|
finished: false,
|
|
590
1295
|
finishedAt: null,
|
|
591
1296
|
winnerId: null,
|
|
@@ -614,15 +1319,118 @@ export class F1RaceSimulation {
|
|
|
614
1319
|
this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
|
|
615
1320
|
standingStart: this.raceControl.mode === 'pre-start',
|
|
616
1321
|
}));
|
|
1322
|
+
this.assignPitLaneTeams();
|
|
1323
|
+
this.initializePitStops();
|
|
617
1324
|
this.recalculateRaceState({ updateDrs: false });
|
|
618
1325
|
this.cars.forEach((car) => resetTimingHistory(car, this.time));
|
|
1326
|
+
this.cars.forEach((car) => resetTimingLineCrossings(car, this.time));
|
|
619
1327
|
this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
|
|
620
1328
|
}
|
|
621
1329
|
|
|
1330
|
+
assignPitLaneTeams() {
|
|
1331
|
+
const pitLane = this.track.pitLane;
|
|
1332
|
+
if (!pitLane?.enabled || !Array.isArray(pitLane.boxes)) return;
|
|
1333
|
+
const boxesPerTeam = pitLane.boxesPerTeam ?? 2;
|
|
1334
|
+
const teamCount = pitLane.teamCount ?? Math.ceil(pitLane.boxes.length / boxesPerTeam);
|
|
1335
|
+
|
|
1336
|
+
pitLane.teams = Array.from({ length: teamCount }, (_, teamIndex) => {
|
|
1337
|
+
const primary = this.cars[teamIndex * boxesPerTeam] ?? this.cars[teamIndex] ?? null;
|
|
1338
|
+
const secondary = this.cars[teamIndex * boxesPerTeam + 1] ?? null;
|
|
1339
|
+
const team = primary?.team ?? secondary?.team ?? null;
|
|
1340
|
+
const id = team?.id ?? `team-${teamIndex + 1}`;
|
|
1341
|
+
const name = team?.name ?? (primary ? `${primary.name} Team` : `Team ${teamIndex + 1}`);
|
|
1342
|
+
const color = team?.color ?? primary?.color ?? secondary?.color ?? '#f8fafc';
|
|
1343
|
+
const pitCrew = normalizePitCrewStats(
|
|
1344
|
+
team?.pitCrew ?? team?.pitCrewStats ?? primary?.team?.pitCrew ?? secondary?.team?.pitCrew,
|
|
1345
|
+
);
|
|
1346
|
+
const boxIds = pitLane.boxes
|
|
1347
|
+
.filter((box) => box.teamIndex === teamIndex)
|
|
1348
|
+
.map((box) => box.id);
|
|
1349
|
+
|
|
1350
|
+
pitLane.boxes
|
|
1351
|
+
.filter((box) => box.teamIndex === teamIndex)
|
|
1352
|
+
.forEach((box) => {
|
|
1353
|
+
box.teamId = id;
|
|
1354
|
+
box.teamName = name;
|
|
1355
|
+
box.teamColor = color;
|
|
1356
|
+
});
|
|
1357
|
+
const serviceArea = pitLane.serviceAreas?.[teamIndex] ?? null;
|
|
1358
|
+
if (serviceArea) {
|
|
1359
|
+
serviceArea.teamId = id;
|
|
1360
|
+
serviceArea.teamName = name;
|
|
1361
|
+
serviceArea.teamColor = color;
|
|
1362
|
+
serviceArea.pitCrew = pitCrew;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return {
|
|
1366
|
+
id,
|
|
1367
|
+
name,
|
|
1368
|
+
color,
|
|
1369
|
+
index: teamIndex,
|
|
1370
|
+
boxIds,
|
|
1371
|
+
serviceAreaId: serviceArea?.id ?? null,
|
|
1372
|
+
pitCrew,
|
|
1373
|
+
};
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
initializePitStops() {
|
|
1378
|
+
const pitLane = this.track.pitLane;
|
|
1379
|
+
const pitStops = this.rules.modules?.pitStops;
|
|
1380
|
+
if (!pitStops?.enabled || !pitLane?.enabled || !Array.isArray(pitLane.boxes) || this.totalLaps < 2) return;
|
|
1381
|
+
const boxesPerTeam = pitLane.boxesPerTeam ?? 2;
|
|
1382
|
+
const maxConcurrentPitLaneCars = Math.max(1, Math.floor(pitStops.maxConcurrentPitLaneCars ?? 3));
|
|
1383
|
+
const pitWindowLapCount = Math.max(1, Math.min(
|
|
1384
|
+
this.totalLaps - 1,
|
|
1385
|
+
Math.ceil(this.cars.length / maxConcurrentPitLaneCars),
|
|
1386
|
+
));
|
|
1387
|
+
|
|
1388
|
+
this.cars.forEach((car, index) => {
|
|
1389
|
+
const stopLapBase = this.track.length * (1 + (index % pitWindowLapCount));
|
|
1390
|
+
const trainPosition = Math.floor(index / pitWindowLapCount);
|
|
1391
|
+
const teamIndex = Math.floor(index / boxesPerTeam);
|
|
1392
|
+
const teamBoxIndex = index % boxesPerTeam;
|
|
1393
|
+
const garageBoxIndex = Math.min(pitLane.boxes.length - 1, teamIndex * boxesPerTeam + teamBoxIndex);
|
|
1394
|
+
const garageBox = pitLane.boxes[garageBoxIndex] ?? pitLane.boxes[index % pitLane.boxes.length];
|
|
1395
|
+
const serviceArea = pitLane.serviceAreas?.[teamIndex] ??
|
|
1396
|
+
pitLane.serviceAreas?.[index % pitLane.serviceAreas.length] ??
|
|
1397
|
+
garageBox;
|
|
1398
|
+
car.pitStop = {
|
|
1399
|
+
status: 'pending',
|
|
1400
|
+
phase: null,
|
|
1401
|
+
boxIndex: serviceArea.index,
|
|
1402
|
+
boxId: serviceArea.id,
|
|
1403
|
+
garageBoxIndex,
|
|
1404
|
+
garageBoxId: garageBox.id,
|
|
1405
|
+
teamId: serviceArea.teamId ?? garageBox.teamId ?? null,
|
|
1406
|
+
teamColor: serviceArea.teamColor ?? garageBox.teamColor ?? null,
|
|
1407
|
+
stopsCompleted: 0,
|
|
1408
|
+
entryRaceDistance: stopLapBase + pitLane.entry.distanceFromStart,
|
|
1409
|
+
plannedRaceDistance: stopLapBase + pitLane.entry.distanceFromStart -
|
|
1410
|
+
PIT_ENTRY_APPROACH_DISTANCE,
|
|
1411
|
+
trainPosition,
|
|
1412
|
+
lapBase: stopLapBase,
|
|
1413
|
+
serviceRemaining: 0,
|
|
1414
|
+
penaltyServiceRemaining: 0,
|
|
1415
|
+
penaltyServiceTotal: 0,
|
|
1416
|
+
servingPenaltyIds: [],
|
|
1417
|
+
serviceProfile: null,
|
|
1418
|
+
queueingForService: false,
|
|
1419
|
+
route: null,
|
|
1420
|
+
routeProgress: 0,
|
|
1421
|
+
routeStartRaceDistance: null,
|
|
1422
|
+
routeEndRaceDistance: null,
|
|
1423
|
+
targetTire: firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds),
|
|
1424
|
+
intent: PIT_INTENT_NONE,
|
|
1425
|
+
};
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
622
1429
|
setSafetyCar(deployed) {
|
|
623
1430
|
const next = Boolean(deployed);
|
|
624
1431
|
if (this.raceControl.finished) return;
|
|
625
1432
|
if (next && this.raceControl.mode === 'pre-start') return;
|
|
1433
|
+
if (this.raceControl.redFlag) return;
|
|
626
1434
|
if (next === this.safetyCar.deployed) return;
|
|
627
1435
|
const ordered = this.orderedCars();
|
|
628
1436
|
this.safetyCar.deployed = next;
|
|
@@ -645,9 +1453,53 @@ export class F1RaceSimulation {
|
|
|
645
1453
|
this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
|
|
646
1454
|
}
|
|
647
1455
|
|
|
1456
|
+
setRedFlag(deployed) {
|
|
1457
|
+
const next = Boolean(deployed);
|
|
1458
|
+
if (this.raceControl.finished) return;
|
|
1459
|
+
if (next && this.raceControl.mode === 'pre-start') return;
|
|
1460
|
+
if (next === Boolean(this.raceControl.redFlag)) return;
|
|
1461
|
+
this.raceControl.redFlag = next;
|
|
1462
|
+
if (next) {
|
|
1463
|
+
this.safetyCar.deployed = false;
|
|
1464
|
+
this.raceControl.mode = 'red-flag';
|
|
1465
|
+
this.raceControl.frozenOrder = this.orderedCars().map((car) => car.id);
|
|
1466
|
+
this.cars.forEach((car) => {
|
|
1467
|
+
car.speed = 0;
|
|
1468
|
+
car.throttle = 0;
|
|
1469
|
+
car.brake = 1;
|
|
1470
|
+
car.drsActive = false;
|
|
1471
|
+
car.drsEligible = false;
|
|
1472
|
+
car.drsZoneId = null;
|
|
1473
|
+
car.drsZoneEnabled = false;
|
|
1474
|
+
car.canAttack = false;
|
|
1475
|
+
});
|
|
1476
|
+
} else {
|
|
1477
|
+
this.raceControl.mode = this.safetyCar.deployed ? 'safety-car' : 'green';
|
|
1478
|
+
this.raceControl.frozenOrder = this.safetyCar.deployed
|
|
1479
|
+
? this.orderedCars().map((car) => car.id)
|
|
1480
|
+
: null;
|
|
1481
|
+
this.cars.forEach((car) => {
|
|
1482
|
+
if (!car.finished) {
|
|
1483
|
+
car.speed = Math.max(car.speed, kphToSimSpeed(60));
|
|
1484
|
+
car.brake = 0;
|
|
1485
|
+
car.throttle = Math.max(car.throttle ?? 0, 0.35);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
this.events.unshift({ type: next ? 'red-flag' : 'green-flag', at: this.time });
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
setPitLaneOpen(open) {
|
|
1493
|
+
const next = Boolean(open);
|
|
1494
|
+
if (next === Boolean(this.raceControl.pitLaneOpen)) return;
|
|
1495
|
+
this.raceControl.pitLaneOpen = next;
|
|
1496
|
+
this.events.unshift({ type: next ? 'pit-lane-open' : 'pit-lane-closed', at: this.time });
|
|
1497
|
+
}
|
|
1498
|
+
|
|
648
1499
|
setCarState(id, partial) {
|
|
649
1500
|
const car = this.cars.find((item) => item.id === id);
|
|
650
1501
|
if (!car) return;
|
|
1502
|
+
const hasExplicitRaceDistance = partial.raceDistance != null;
|
|
651
1503
|
Object.assign(car, partial);
|
|
652
1504
|
if (
|
|
653
1505
|
partial.x != null ||
|
|
@@ -662,9 +1514,21 @@ export class F1RaceSimulation {
|
|
|
662
1514
|
}
|
|
663
1515
|
car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
|
|
664
1516
|
car.heading = normalizeAngle(car.heading);
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1517
|
+
const centerState = nearestTrackState(this.track, car, car.progress ?? car.raceDistance);
|
|
1518
|
+
applyWheelSurfaceState(car, this.track, { centerState });
|
|
1519
|
+
if (
|
|
1520
|
+
partial.desiredOffset == null &&
|
|
1521
|
+
(partial.x != null || partial.y != null || partial.progress != null || partial.raceDistance != null)
|
|
1522
|
+
) {
|
|
1523
|
+
car.desiredOffset = centerState.signedOffset ?? 0;
|
|
1524
|
+
}
|
|
1525
|
+
if ((partial.x != null || partial.y != null) && car.pitStop?.route) {
|
|
1526
|
+
car.pitStop.routeProgress = nearestDistanceOnRoute(car.pitStop.route, car, car.pitStop.routeProgress ?? 0);
|
|
1527
|
+
}
|
|
1528
|
+
if (!hasExplicitRaceDistance) {
|
|
1529
|
+
const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
|
|
1530
|
+
car.raceDistance = (car.raceDistance ?? car.trackState.distance) + delta;
|
|
1531
|
+
}
|
|
668
1532
|
car.progress = car.trackState.distance;
|
|
669
1533
|
car.lap = this.computeLap(car.raceDistance);
|
|
670
1534
|
car.drsZoneId = null;
|
|
@@ -673,6 +1537,7 @@ export class F1RaceSimulation {
|
|
|
673
1537
|
car.drsEligible = false;
|
|
674
1538
|
car.drsDetection = {};
|
|
675
1539
|
resetTimingHistory(car, this.time);
|
|
1540
|
+
resetTimingLineCrossings(car, this.time);
|
|
676
1541
|
resetLapTelemetry(car, this.time, this.track, this.totalLaps);
|
|
677
1542
|
this.recalculateRaceState({ updateDrs: false });
|
|
678
1543
|
this.evaluateRaceFinish();
|
|
@@ -689,8 +1554,712 @@ export class F1RaceSimulation {
|
|
|
689
1554
|
if (car) car.manualControls = null;
|
|
690
1555
|
}
|
|
691
1556
|
|
|
692
|
-
|
|
693
|
-
const
|
|
1557
|
+
setAutomaticPitIntentEnabled(id, enabled) {
|
|
1558
|
+
const car = this.cars.find((item) => item.id === id);
|
|
1559
|
+
if (!car) return false;
|
|
1560
|
+
car.automaticPitIntentEnabled = Boolean(enabled);
|
|
1561
|
+
return true;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
getPitIntent(id) {
|
|
1565
|
+
const car = this.cars.find((item) => item.id === id);
|
|
1566
|
+
return normalizePitIntent(car?.pitStop?.intent) ?? PIT_INTENT_NONE;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
getPitTargetCompound(id) {
|
|
1570
|
+
const car = this.cars.find((item) => item.id === id);
|
|
1571
|
+
return car?.pitStop?.targetTire ?? null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
setPitIntent(id, intent, targetCompound = undefined) {
|
|
1575
|
+
const request = intent && typeof intent === 'object'
|
|
1576
|
+
? intent
|
|
1577
|
+
: { intent, targetCompound };
|
|
1578
|
+
const nextIntent = normalizePitIntent(request.intent ?? request.pitIntent);
|
|
1579
|
+
if (nextIntent == null) return false;
|
|
1580
|
+
const car = this.cars.find((item) => item.id === id);
|
|
1581
|
+
const stop = car?.pitStop;
|
|
1582
|
+
const pitStops = this.rules.modules?.pitStops;
|
|
1583
|
+
if (!car || !stop || !pitStops?.enabled || !this.track.pitLane?.enabled) return false;
|
|
1584
|
+
if (this.isCarInActivePitStop(car)) return false;
|
|
1585
|
+
|
|
1586
|
+
const compoundRequest = request.targetCompound ??
|
|
1587
|
+
request.compound ??
|
|
1588
|
+
request.pitCompound ??
|
|
1589
|
+
request.pitTargetCompound ??
|
|
1590
|
+
request.targetTire;
|
|
1591
|
+
const hasCompoundRequest = compoundRequest != null && compoundRequest !== '';
|
|
1592
|
+
if (hasCompoundRequest) {
|
|
1593
|
+
const targetTire = normalizePitCompound(compoundRequest, this.rules.modules?.tireStrategy?.compounds);
|
|
1594
|
+
if (!targetTire) return false;
|
|
1595
|
+
stop.targetTire = targetTire;
|
|
1596
|
+
} else if (nextIntent !== PIT_INTENT_NONE && (!stop.targetTire || stop.status === 'completed')) {
|
|
1597
|
+
stop.targetTire = firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
|
|
1598
|
+
} else if (nextIntent === PIT_INTENT_NONE) {
|
|
1599
|
+
stop.targetTire = firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
stop.intent = nextIntent;
|
|
1603
|
+
if (stop.status === 'pending' && nextIntent !== PIT_INTENT_NONE) {
|
|
1604
|
+
this.schedulePitStopAtNextEntry(car, stop);
|
|
1605
|
+
} else if (stop.status === 'completed' && nextIntent !== PIT_INTENT_NONE) {
|
|
1606
|
+
this.rearmCompletedPitStop(car, stop);
|
|
1607
|
+
}
|
|
1608
|
+
return true;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
isCarInActivePitStop(car) {
|
|
1612
|
+
return Boolean(car.pitStop && car.pitStop.status !== 'pending' && car.pitStop.status !== 'completed');
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
schedulePitStopAtNextEntry(car, stop) {
|
|
1616
|
+
const pitLane = this.track.pitLane;
|
|
1617
|
+
if (!stop || !pitLane?.entry || !Number.isFinite(this.track.length) || this.track.length <= 0) return false;
|
|
1618
|
+
const raceDistance = car.raceDistance ?? 0;
|
|
1619
|
+
const entryOffset = pitLane.entry.distanceFromStart ?? 0;
|
|
1620
|
+
let lapBase = Math.floor((raceDistance - entryOffset) / this.track.length) * this.track.length;
|
|
1621
|
+
let entryRaceDistance = lapBase + entryOffset;
|
|
1622
|
+
|
|
1623
|
+
while (raceDistance > entryRaceDistance + metersToSimUnits(90)) {
|
|
1624
|
+
lapBase += this.track.length;
|
|
1625
|
+
entryRaceDistance = lapBase + entryOffset;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
stop.lapBase = lapBase;
|
|
1629
|
+
stop.entryRaceDistance = entryRaceDistance;
|
|
1630
|
+
stop.plannedRaceDistance = entryRaceDistance - PIT_ENTRY_APPROACH_DISTANCE;
|
|
1631
|
+
return true;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
rearmCompletedPitStop(car, stop) {
|
|
1635
|
+
if (!stop || stop.status !== 'completed') return false;
|
|
1636
|
+
stop.status = 'pending';
|
|
1637
|
+
stop.phase = null;
|
|
1638
|
+
stop.route = null;
|
|
1639
|
+
stop.routeProgress = 0;
|
|
1640
|
+
stop.routeStartRaceDistance = null;
|
|
1641
|
+
stop.routeEndRaceDistance = null;
|
|
1642
|
+
stop.serviceRemaining = 0;
|
|
1643
|
+
stop.penaltyServiceRemaining = 0;
|
|
1644
|
+
stop.penaltyServiceTotal = 0;
|
|
1645
|
+
stop.servingPenaltyIds = [];
|
|
1646
|
+
stop.queueingForService = false;
|
|
1647
|
+
stop.serviceProfile = null;
|
|
1648
|
+
stop.targetTire = stop.targetTire ?? firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
|
|
1649
|
+
return this.schedulePitStopAtNextEntry(car, stop);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
canStartPitStop(car) {
|
|
1653
|
+
const stop = car.pitStop;
|
|
1654
|
+
const pitStops = this.rules.modules?.pitStops;
|
|
1655
|
+
if (!stop || !pitStops?.enabled) return false;
|
|
1656
|
+
const active = this.cars.filter((candidate) => candidate !== car && this.isCarInActivePitStop(candidate));
|
|
1657
|
+
const maxConcurrentPitLaneCars = Math.max(1, Math.floor(pitStops.maxConcurrentPitLaneCars ?? 3));
|
|
1658
|
+
if (active.length >= maxConcurrentPitLaneCars) return false;
|
|
1659
|
+
|
|
1660
|
+
if (!pitStops.doubleStacking && stop.teamId) {
|
|
1661
|
+
const box = this.getPitStopBox(stop);
|
|
1662
|
+
if (this.isPitServiceQueueOccupied(car, box)) return false;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const minimumGap = Math.max(0, pitStops.minimumPitLaneGap ?? 0);
|
|
1666
|
+
const candidateDistance = car.raceDistance ?? 0;
|
|
1667
|
+
return active.every((candidate) => {
|
|
1668
|
+
const gap = (candidate.raceDistance ?? 0) - candidateDistance;
|
|
1669
|
+
return gap < 0 || gap >= minimumGap;
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
shouldStartPitStop(car) {
|
|
1674
|
+
const stop = car.pitStop;
|
|
1675
|
+
if (!stop || stop.status !== 'pending' || car.finished) return false;
|
|
1676
|
+
if (!pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops).open) return false;
|
|
1677
|
+
const intent = normalizePitIntent(stop.intent) ?? PIT_INTENT_NONE;
|
|
1678
|
+
if (intent === PIT_INTENT_NONE) return false;
|
|
1679
|
+
const raceDistance = car.raceDistance ?? 0;
|
|
1680
|
+
if (raceDistance > stop.entryRaceDistance + metersToSimUnits(90)) {
|
|
1681
|
+
this.schedulePitStopAtNextEntry(car, stop);
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
return raceDistance >= stop.plannedRaceDistance &&
|
|
1685
|
+
(intent === PIT_INTENT_COMMITTED || this.canStartPitStop(car));
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
updateAutomaticPitIntent(car) {
|
|
1689
|
+
const stop = car.pitStop;
|
|
1690
|
+
const pitStops = this.rules.modules?.pitStops;
|
|
1691
|
+
if (!stop || !['pending', 'completed'].includes(stop.status) || !pitStops?.enabled || car.finished) return;
|
|
1692
|
+
if (car.automaticPitIntentEnabled === false) return;
|
|
1693
|
+
const tireEnergy = Number(car.tireEnergy ?? 100);
|
|
1694
|
+
const requestThreshold = Number(pitStops.tirePitRequestThresholdPercent ?? 50);
|
|
1695
|
+
const commitThreshold = Number(pitStops.tirePitCommitThresholdPercent ?? 30);
|
|
1696
|
+
if (!Number.isFinite(tireEnergy)) return;
|
|
1697
|
+
|
|
1698
|
+
let nextIntent = PIT_INTENT_NONE;
|
|
1699
|
+
if (tireEnergy < commitThreshold) nextIntent = PIT_INTENT_COMMITTED;
|
|
1700
|
+
else if (tireEnergy < requestThreshold) nextIntent = PIT_INTENT_IF_FREE;
|
|
1701
|
+
if (nextIntent === PIT_INTENT_NONE || nextIntent <= (normalizePitIntent(stop.intent) ?? PIT_INTENT_NONE)) return;
|
|
1702
|
+
|
|
1703
|
+
stop.intent = nextIntent;
|
|
1704
|
+
if (stop.status === 'completed') this.rearmCompletedPitStop(car, stop);
|
|
1705
|
+
else this.schedulePitStopAtNextEntry(car, stop);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
getPitStopBox(stop) {
|
|
1709
|
+
return this.track.pitLane?.serviceAreas?.find((box) => box.id === stop?.boxId) ??
|
|
1710
|
+
this.track.pitLane?.boxes?.find((box) => box.id === stop?.boxId) ??
|
|
1711
|
+
null;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
isPitServiceBusy(car, box) {
|
|
1715
|
+
return this.cars.some((candidate) => (
|
|
1716
|
+
candidate !== car &&
|
|
1717
|
+
candidate.pitStop?.boxId === box?.id &&
|
|
1718
|
+
this.isPitServiceAreaOccupied(candidate, box)
|
|
1719
|
+
));
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
isPitServiceAreaOccupied(candidate, box) {
|
|
1723
|
+
const status = candidate?.pitStop?.status;
|
|
1724
|
+
const phase = candidate?.pitStop?.phase;
|
|
1725
|
+
if (!status || !box) return false;
|
|
1726
|
+
if (status === 'servicing') return true;
|
|
1727
|
+
if (status === 'entering' && phase === 'queue-release') return true;
|
|
1728
|
+
if (status === 'exiting') return pointDistance(candidate, box.center) < PIT_SERVICE_CLEAR_DISTANCE;
|
|
1729
|
+
return false;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
isPitServiceQueueOccupied(car, box) {
|
|
1733
|
+
return this.cars.some((candidate) => (
|
|
1734
|
+
candidate !== car &&
|
|
1735
|
+
candidate.pitStop?.boxId === box?.id &&
|
|
1736
|
+
(
|
|
1737
|
+
candidate.pitStop?.status === 'queued' ||
|
|
1738
|
+
Boolean(candidate.pitStop?.queueingForService)
|
|
1739
|
+
)
|
|
1740
|
+
));
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
getPitBoxRaceDistance(stop, box) {
|
|
1744
|
+
const pitLane = this.track.pitLane;
|
|
1745
|
+
const amount = pitLane?.mainLane?.length > 0 ? box.distanceAlongLane / pitLane.mainLane.length : 0;
|
|
1746
|
+
return stop.lapBase + pitLane.entry.distanceFromStart +
|
|
1747
|
+
(pitLane.exit.distanceFromStart - pitLane.entry.distanceFromStart) * amount;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
startPitStop(car) {
|
|
1751
|
+
const stop = car.pitStop;
|
|
1752
|
+
const box = this.getPitStopBox(stop);
|
|
1753
|
+
const pitLane = this.track.pitLane;
|
|
1754
|
+
if (!stop || !box || !pitLane?.enabled) return false;
|
|
1755
|
+
|
|
1756
|
+
car.gridLocked = false;
|
|
1757
|
+
car.drsActive = false;
|
|
1758
|
+
car.drsEligible = false;
|
|
1759
|
+
car.drsZoneId = null;
|
|
1760
|
+
car.drsZoneEnabled = false;
|
|
1761
|
+
car.canAttack = false;
|
|
1762
|
+
stop.targetTire = stop.targetTire ?? firstDifferentCompound(car.tire, this.rules.modules?.tireStrategy?.compounds);
|
|
1763
|
+
|
|
1764
|
+
const currentState = applyWheelSurfaceState(car, this.track).representativeState;
|
|
1765
|
+
this.events.unshift({
|
|
1766
|
+
type: 'pit-entry',
|
|
1767
|
+
at: this.time,
|
|
1768
|
+
carId: car.id,
|
|
1769
|
+
boxId: box.id,
|
|
1770
|
+
teamId: box.teamId ?? null,
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
if (currentState.surface === 'pit-box' && currentState.pitBoxId === box.id) {
|
|
1774
|
+
this.beginPitService(car, box);
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const hasServiceQueue = Boolean(box.queuePoint);
|
|
1779
|
+
const shouldStageForService = hasServiceQueue;
|
|
1780
|
+
const queueDistanceAlongLane = box.queueDistanceAlongLane ??
|
|
1781
|
+
Math.max(0, box.distanceAlongLane - PIT_SERVICE_QUEUE_FALLBACK_GAP);
|
|
1782
|
+
const stopDistanceAlongLane = shouldStageForService
|
|
1783
|
+
? queueDistanceAlongLane
|
|
1784
|
+
: box.distanceAlongLane;
|
|
1785
|
+
const serviceTarget = shouldStageForService ? box.queuePoint : box.center;
|
|
1786
|
+
const driveLaneOffset = pitDriveLaneOffset(pitLane);
|
|
1787
|
+
const boxApproachDistance = Math.max(
|
|
1788
|
+
0,
|
|
1789
|
+
(hasServiceQueue ? queueDistanceAlongLane : stopDistanceAlongLane) - PIT_BOX_APPROACH_DISTANCE,
|
|
1790
|
+
);
|
|
1791
|
+
const route = createRoute([
|
|
1792
|
+
...createPitApproachPoints(this.track, car, pitLane, stop.entryRaceDistance),
|
|
1793
|
+
...(pitLane.entry.roadCenterline ?? []).map((point) => routePoint(point)),
|
|
1794
|
+
routePoint(pitMainLanePointAt(pitLane, 0, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
|
|
1795
|
+
routePoint(pitMainLanePointAt(pitLane, boxApproachDistance, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
|
|
1796
|
+
routePoint(serviceTarget, pitLane.mainLane.heading, { limiterActive: true }),
|
|
1797
|
+
]);
|
|
1798
|
+
stop.status = 'entering';
|
|
1799
|
+
stop.phase = 'entry';
|
|
1800
|
+
stop.queueingForService = shouldStageForService;
|
|
1801
|
+
stop.route = route;
|
|
1802
|
+
stop.routeProgress = 0;
|
|
1803
|
+
stop.routeStartRaceDistance = car.raceDistance ?? stop.plannedRaceDistance;
|
|
1804
|
+
stop.routeEndRaceDistance = this.getPitBoxRaceDistance(stop, {
|
|
1805
|
+
...box,
|
|
1806
|
+
distanceAlongLane: stopDistanceAlongLane,
|
|
1807
|
+
});
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
beginPitQueue(car, box) {
|
|
1812
|
+
const stop = car.pitStop;
|
|
1813
|
+
if (!stop || !box?.queuePoint) return false;
|
|
1814
|
+
stop.status = 'queued';
|
|
1815
|
+
stop.phase = 'queue';
|
|
1816
|
+
stop.route = null;
|
|
1817
|
+
stop.routeProgress = 0;
|
|
1818
|
+
stop.queueingForService = false;
|
|
1819
|
+
car.x = box.queuePoint.x;
|
|
1820
|
+
car.y = box.queuePoint.y;
|
|
1821
|
+
car.heading = this.track.pitLane.mainLane.heading;
|
|
1822
|
+
car.speed = 0;
|
|
1823
|
+
car.throttle = 0;
|
|
1824
|
+
car.brake = 1;
|
|
1825
|
+
applyWheelSurfaceState(car, this.track);
|
|
1826
|
+
car.progress = car.trackState.distance;
|
|
1827
|
+
car.raceDistance = this.getPitBoxRaceDistance(stop, {
|
|
1828
|
+
...box,
|
|
1829
|
+
distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
|
|
1830
|
+
});
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
releasePitQueue(car, box, { fromCurrent = false } = {}) {
|
|
1835
|
+
const stop = car.pitStop;
|
|
1836
|
+
if (!stop || !box?.center) return false;
|
|
1837
|
+
const route = createRoute([
|
|
1838
|
+
...(fromCurrent ? [routePoint(car, car.heading, { limiterActive: true })] : []),
|
|
1839
|
+
routePoint(box.queuePoint, this.track.pitLane.mainLane.heading, { limiterActive: true }),
|
|
1840
|
+
routePoint(box.center, this.track.pitLane.mainLane.heading, { limiterActive: true }),
|
|
1841
|
+
]);
|
|
1842
|
+
stop.status = 'entering';
|
|
1843
|
+
stop.phase = 'queue-release';
|
|
1844
|
+
stop.queueingForService = false;
|
|
1845
|
+
stop.route = route;
|
|
1846
|
+
stop.routeProgress = 0;
|
|
1847
|
+
stop.routeStartRaceDistance = fromCurrent
|
|
1848
|
+
? car.raceDistance ?? this.getPitBoxRaceDistance(stop, {
|
|
1849
|
+
...box,
|
|
1850
|
+
distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
|
|
1851
|
+
})
|
|
1852
|
+
: this.getPitBoxRaceDistance(stop, {
|
|
1853
|
+
...box,
|
|
1854
|
+
distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
|
|
1855
|
+
});
|
|
1856
|
+
stop.routeEndRaceDistance = this.getPitBoxRaceDistance(stop, box);
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
beginPitService(car, box) {
|
|
1861
|
+
const stop = car.pitStop;
|
|
1862
|
+
stop.status = 'servicing';
|
|
1863
|
+
stop.route = null;
|
|
1864
|
+
stop.routeProgress = 0;
|
|
1865
|
+
car.x = box.center.x;
|
|
1866
|
+
car.y = box.center.y;
|
|
1867
|
+
car.heading = this.track.pitLane.mainLane.heading;
|
|
1868
|
+
car.speed = 0;
|
|
1869
|
+
car.throttle = 0;
|
|
1870
|
+
car.brake = 1;
|
|
1871
|
+
car.steeringAngle = 0;
|
|
1872
|
+
car.yawRate = 0;
|
|
1873
|
+
car.turnRadius = Infinity;
|
|
1874
|
+
applyWheelSurfaceState(car, this.track);
|
|
1875
|
+
car.progress = car.trackState.distance;
|
|
1876
|
+
car.raceDistance = this.getPitBoxRaceDistance(stop, box);
|
|
1877
|
+
|
|
1878
|
+
if (this.beginPitPenaltyService(car)) return;
|
|
1879
|
+
this.beginTireService(car);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
beginPitPenaltyService(car) {
|
|
1883
|
+
const stop = car.pitStop;
|
|
1884
|
+
const penalties = this.getPitServicePenalties(car.id);
|
|
1885
|
+
if (!stop || !penalties.length) return false;
|
|
1886
|
+
|
|
1887
|
+
const totalSeconds = penalties.reduce((total, penalty) => (
|
|
1888
|
+
total + getPitServicePenaltySeconds(penalty)
|
|
1889
|
+
), 0);
|
|
1890
|
+
stop.servingPenaltyIds = penalties.map((penalty) => penalty.id);
|
|
1891
|
+
stop.penaltyServiceTotal = totalSeconds;
|
|
1892
|
+
stop.penaltyServiceRemaining = totalSeconds;
|
|
1893
|
+
|
|
1894
|
+
if (totalSeconds <= 0) {
|
|
1895
|
+
this.completePitPenaltyService(car);
|
|
1896
|
+
return true;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
stop.phase = 'penalty';
|
|
1900
|
+
stop.serviceRemaining = totalSeconds;
|
|
1901
|
+
this.events.unshift({
|
|
1902
|
+
type: 'pit-penalty-service-start',
|
|
1903
|
+
at: this.time,
|
|
1904
|
+
carId: car.id,
|
|
1905
|
+
penaltyIds: [...stop.servingPenaltyIds],
|
|
1906
|
+
penaltyServiceSeconds: totalSeconds,
|
|
1907
|
+
});
|
|
1908
|
+
return true;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
calculatePitServiceProfile(car) {
|
|
1912
|
+
const pitStops = this.rules.modules?.pitStops ?? {};
|
|
1913
|
+
const variability = pitStops.variability ?? {};
|
|
1914
|
+
const baseSeconds = Math.max(0, Number(pitStops.defaultStopSeconds) || 2.8);
|
|
1915
|
+
const box = this.getPitStopBox(car?.pitStop);
|
|
1916
|
+
const team = this.track.pitLane?.teams?.find((entry) => entry.id === car?.pitStop?.teamId);
|
|
1917
|
+
const pitCrew = normalizePitCrewStats(
|
|
1918
|
+
box?.pitCrew ?? team?.pitCrew ?? car?.team?.pitCrew ?? car?.team?.pitCrewStats,
|
|
1919
|
+
);
|
|
1920
|
+
const profile = {
|
|
1921
|
+
baseSeconds,
|
|
1922
|
+
seconds: baseSeconds,
|
|
1923
|
+
perfect: Boolean(variability.perfect),
|
|
1924
|
+
variabilityEnabled: Boolean(variability.enabled),
|
|
1925
|
+
teamId: car?.pitStop?.teamId ?? box?.teamId ?? null,
|
|
1926
|
+
pitCrew,
|
|
1927
|
+
speedDeltaSeconds: 0,
|
|
1928
|
+
consistencyDeltaSeconds: 0,
|
|
1929
|
+
issueDelaySeconds: 0,
|
|
1930
|
+
issue: null,
|
|
1931
|
+
};
|
|
1932
|
+
|
|
1933
|
+
if (!variability.enabled || variability.perfect) return profile;
|
|
1934
|
+
|
|
1935
|
+
const speedImpact = Math.max(0, Number(variability.speedImpactSeconds) || 0);
|
|
1936
|
+
const jitterImpact = Math.max(0, Number(variability.consistencyJitterSeconds) || 0);
|
|
1937
|
+
const issueChance = clamp(Number(variability.issueChance) || 0, 0, 1);
|
|
1938
|
+
const issueMaxDelay = Math.max(0, Number(variability.issueMaxDelaySeconds) || 0);
|
|
1939
|
+
profile.speedDeltaSeconds = (0.5 - pitCrew.speed) * speedImpact;
|
|
1940
|
+
profile.consistencyDeltaSeconds = (this.random() - 0.5) * (1 - pitCrew.consistency) * jitterImpact;
|
|
1941
|
+
const effectiveIssueChance = issueChance * (1 - pitCrew.reliability);
|
|
1942
|
+
if (this.random() < effectiveIssueChance) {
|
|
1943
|
+
profile.issueDelaySeconds = 0.35 + this.random() * issueMaxDelay * (1 - pitCrew.reliability);
|
|
1944
|
+
profile.issue = 'slow-stop';
|
|
1945
|
+
}
|
|
1946
|
+
profile.seconds = Math.max(
|
|
1947
|
+
Math.min(1.6, baseSeconds),
|
|
1948
|
+
baseSeconds + profile.speedDeltaSeconds + profile.consistencyDeltaSeconds + profile.issueDelaySeconds,
|
|
1949
|
+
);
|
|
1950
|
+
return profile;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
beginTireService(car) {
|
|
1954
|
+
const stop = car.pitStop;
|
|
1955
|
+
if (!stop) return;
|
|
1956
|
+
const serviceProfile = this.calculatePitServiceProfile(car);
|
|
1957
|
+
stop.phase = 'service';
|
|
1958
|
+
stop.penaltyServiceRemaining = 0;
|
|
1959
|
+
stop.serviceProfile = serviceProfile;
|
|
1960
|
+
stop.serviceRemaining = serviceProfile.seconds;
|
|
1961
|
+
this.events.unshift({
|
|
1962
|
+
type: 'pit-stop-start',
|
|
1963
|
+
at: this.time,
|
|
1964
|
+
carId: car.id,
|
|
1965
|
+
boxId: stop.boxId,
|
|
1966
|
+
teamId: stop.teamId ?? null,
|
|
1967
|
+
targetTire: stop.targetTire,
|
|
1968
|
+
serviceSeconds: serviceProfile.seconds,
|
|
1969
|
+
serviceIssue: serviceProfile.issue,
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
completePitPenaltyService(car) {
|
|
1974
|
+
const stop = car.pitStop;
|
|
1975
|
+
if (!stop) return;
|
|
1976
|
+
const servedIds = [...(stop.servingPenaltyIds ?? [])];
|
|
1977
|
+
servedIds.forEach((penaltyId) => {
|
|
1978
|
+
const penalty = this.penalties.find((entry) => entry.id === penaltyId);
|
|
1979
|
+
const beforeStatus = penalty?.status;
|
|
1980
|
+
const result = servePitPenaltyRecord(penalty, this.time);
|
|
1981
|
+
if (result && beforeStatus !== result.status) {
|
|
1982
|
+
this.events.unshift({
|
|
1983
|
+
type: 'penalty-served',
|
|
1984
|
+
at: this.time,
|
|
1985
|
+
penaltyId: result.id,
|
|
1986
|
+
driverId: result.driverId,
|
|
1987
|
+
serviceType: result.serviceType,
|
|
1988
|
+
serviceContext: 'pit-stop',
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
stop.penaltyServiceRemaining = 0;
|
|
1993
|
+
stop.serviceRemaining = 0;
|
|
1994
|
+
this.events.unshift({
|
|
1995
|
+
type: 'pit-penalty-service-complete',
|
|
1996
|
+
at: this.time,
|
|
1997
|
+
carId: car.id,
|
|
1998
|
+
penaltyIds: servedIds,
|
|
1999
|
+
});
|
|
2000
|
+
this.beginTireService(car);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
getPitServicePenalties(driverId) {
|
|
2004
|
+
return this.penalties.filter((penalty) => (
|
|
2005
|
+
penalty.driverId === driverId && isPenaltyPitServiceable(penalty)
|
|
2006
|
+
));
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
completePitService(car) {
|
|
2010
|
+
const stop = car.pitStop;
|
|
2011
|
+
const box = this.getPitStopBox(stop);
|
|
2012
|
+
const pitLane = this.track.pitLane;
|
|
2013
|
+
if (!stop || !box || !pitLane?.enabled) return;
|
|
2014
|
+
|
|
2015
|
+
car.tire = stop.targetTire ?? car.tire;
|
|
2016
|
+
car.tireEnergy = 100;
|
|
2017
|
+
if (car.tire && !car.usedTireCompounds.includes(car.tire)) car.usedTireCompounds.push(car.tire);
|
|
2018
|
+
this.events.unshift({
|
|
2019
|
+
type: 'pit-stop-complete',
|
|
2020
|
+
at: this.time,
|
|
2021
|
+
carId: car.id,
|
|
2022
|
+
boxId: box.id,
|
|
2023
|
+
teamId: box.teamId ?? null,
|
|
2024
|
+
tire: car.tire,
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
const driveLaneOffset = pitDriveLaneOffset(pitLane);
|
|
2028
|
+
const boxReleaseDistance = Math.min(pitLane.mainLane.length, box.distanceAlongLane + PIT_BOX_APPROACH_DISTANCE);
|
|
2029
|
+
const route = createRoute([
|
|
2030
|
+
routePoint(box.center, pitLane.mainLane.heading, { limiterActive: true }),
|
|
2031
|
+
routePoint(box.laneTarget, pitLane.mainLane.heading, { limiterActive: true }),
|
|
2032
|
+
routePoint(pitMainLanePointAt(pitLane, boxReleaseDistance, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
|
|
2033
|
+
routePoint(pitMainLanePointAt(pitLane, pitLane.mainLane.length, driveLaneOffset), pitLane.mainLane.heading, { limiterActive: true }),
|
|
2034
|
+
...(pitLane.exit.roadCenterline ?? []).map((point) => routePoint(point)),
|
|
2035
|
+
]);
|
|
2036
|
+
stop.status = 'exiting';
|
|
2037
|
+
stop.phase = 'exit';
|
|
2038
|
+
stop.route = route;
|
|
2039
|
+
stop.routeProgress = 0;
|
|
2040
|
+
stop.routeStartRaceDistance = this.getPitBoxRaceDistance(stop, box);
|
|
2041
|
+
stop.routeEndRaceDistance = stop.lapBase + pitLane.exit.distanceFromStart;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
finishPitExit(car) {
|
|
2045
|
+
const stop = car.pitStop;
|
|
2046
|
+
if (!stop) return;
|
|
2047
|
+
stop.status = 'completed';
|
|
2048
|
+
stop.phase = null;
|
|
2049
|
+
stop.route = null;
|
|
2050
|
+
stop.routeProgress = 0;
|
|
2051
|
+
stop.serviceRemaining = 0;
|
|
2052
|
+
stop.queueingForService = false;
|
|
2053
|
+
stop.stopsCompleted = (stop.stopsCompleted ?? 0) + 1;
|
|
2054
|
+
stop.intent = PIT_INTENT_NONE;
|
|
2055
|
+
car.speed = Math.max(car.speed, kphToSimSpeed(PIT_EXIT_RELEASE_SPEED_KPH));
|
|
2056
|
+
car.throttle = 0.55;
|
|
2057
|
+
car.brake = 0;
|
|
2058
|
+
applyWheelSurfaceState(car, this.track);
|
|
2059
|
+
car.progress = car.trackState.distance;
|
|
2060
|
+
car.raceDistance = Math.max(car.raceDistance ?? 0, stop.routeEndRaceDistance ?? car.raceDistance ?? 0);
|
|
2061
|
+
this.events.unshift({
|
|
2062
|
+
type: 'pit-exit',
|
|
2063
|
+
at: this.time,
|
|
2064
|
+
carId: car.id,
|
|
2065
|
+
boxId: stop.boxId,
|
|
2066
|
+
teamId: stop.teamId ?? null,
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
applyPitRoutePosition(car, delta) {
|
|
2071
|
+
const stop = car.pitStop;
|
|
2072
|
+
if (!stop?.route) return false;
|
|
2073
|
+
const speedLimit = kphToSimSpeed(this.rules.modules?.pitStops?.pitLaneSpeedLimitKph ?? 80);
|
|
2074
|
+
const route = stop.route;
|
|
2075
|
+
if (stop.phase === 'queue-release') {
|
|
2076
|
+
const nextProgress = Math.min(route.length, (stop.routeProgress ?? 0) + PIT_QUEUE_RELEASE_SPEED * delta);
|
|
2077
|
+
const point = sampleRoute(route, nextProgress);
|
|
2078
|
+
if (!point) return false;
|
|
2079
|
+
const previousHeading = car.heading;
|
|
2080
|
+
car.previousX = car.x;
|
|
2081
|
+
car.previousY = car.y;
|
|
2082
|
+
car.previousHeading = car.heading;
|
|
2083
|
+
car.x = point.x;
|
|
2084
|
+
car.y = point.y;
|
|
2085
|
+
car.heading = point.heading ?? this.track.pitLane.mainLane.heading;
|
|
2086
|
+
car.speed = PIT_QUEUE_RELEASE_SPEED;
|
|
2087
|
+
car.throttle = 0.32;
|
|
2088
|
+
car.brake = 0;
|
|
2089
|
+
car.yawRate = normalizeAngle(car.heading - previousHeading) / Math.max(delta, 1e-6);
|
|
2090
|
+
car.steeringAngle = clamp(
|
|
2091
|
+
Math.atan((car.yawRate * VEHICLE_LIMITS.wheelbase) / Math.max(car.speed, 1)),
|
|
2092
|
+
-VEHICLE_LIMITS.maxSteer,
|
|
2093
|
+
VEHICLE_LIMITS.maxSteer,
|
|
2094
|
+
);
|
|
2095
|
+
car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
|
|
2096
|
+
stop.routeProgress = nextProgress;
|
|
2097
|
+
const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
|
|
2098
|
+
car.raceDistance = stop.routeStartRaceDistance +
|
|
2099
|
+
(stop.routeEndRaceDistance - stop.routeStartRaceDistance) * routeAmount;
|
|
2100
|
+
applyWheelSurfaceState(car, this.track);
|
|
2101
|
+
car.progress = car.trackState.distance;
|
|
2102
|
+
car.lap = this.computeLap(car.raceDistance);
|
|
2103
|
+
return route.length - stop.routeProgress <= PIT_QUEUE_RELEASE_FINISH_DISTANCE;
|
|
2104
|
+
}
|
|
2105
|
+
stop.routeProgress = clamp(stop.routeProgress ?? nearestDistanceOnRoute(route, car, 0), 0, route.length);
|
|
2106
|
+
const routeEnd = route.points.at(-1);
|
|
2107
|
+
const limiterActive = routeLimiterActiveAt(route, stop.routeProgress);
|
|
2108
|
+
let targetSpeed = limiterActive
|
|
2109
|
+
? Math.min(speedLimit, VEHICLE_LIMITS.maxSpeed)
|
|
2110
|
+
: Math.min(kphToSimSpeed(150), VEHICLE_LIMITS.maxSpeed);
|
|
2111
|
+
if (!limiterActive && stop.status === 'entering') {
|
|
2112
|
+
targetSpeed = Math.min(targetSpeed, speedLimit + kphToSimSpeed(PIT_ENTRY_CONNECTOR_OVERSPEED_KPH));
|
|
2113
|
+
const distanceToLimiter = distanceToNextLimiterSegment(route, stop.routeProgress);
|
|
2114
|
+
if (distanceToLimiter < PIT_LIMITER_BRAKE_DISTANCE) {
|
|
2115
|
+
targetSpeed = Math.min(
|
|
2116
|
+
targetSpeed,
|
|
2117
|
+
speedLimit + Math.max(0, distanceToLimiter * PIT_LIMITER_APPROACH_SPEED_SLOPE),
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (stop.status === 'entering') {
|
|
2122
|
+
const remainingBefore = Math.max(0, route.length - stop.routeProgress);
|
|
2123
|
+
const captureDistance = stop.queueingForService ? PIT_QUEUE_CAPTURE_DISTANCE : PIT_ENTRY_BOX_CAPTURE_DISTANCE;
|
|
2124
|
+
const brakeZone = stop.queueingForService ? metersToSimUnits(25) : metersToSimUnits(80);
|
|
2125
|
+
if (remainingBefore < brakeZone) {
|
|
2126
|
+
const brakingSlope = stop.queueingForService ? kphToSimSpeed(62) / metersToSimUnits(100) : kphToSimSpeed(24) / metersToSimUnits(100);
|
|
2127
|
+
const captureSpeedFloor = stop.queueingForService ? kphToSimSpeed(42) : kphToSimSpeed(28);
|
|
2128
|
+
const approachSpeed = remainingBefore > captureDistance
|
|
2129
|
+
? Math.max(captureSpeedFloor, remainingBefore * brakingSlope)
|
|
2130
|
+
: remainingBefore * brakingSlope;
|
|
2131
|
+
targetSpeed = Math.min(targetSpeed, clamp(approachSpeed, 0, speedLimit * 0.78));
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const nextProgress = Math.min(route.length, stop.routeProgress + Math.max(0, targetSpeed) * delta);
|
|
2136
|
+
const point = sampleRoute(route, nextProgress);
|
|
2137
|
+
if (!point) return false;
|
|
2138
|
+
const previousSpeed = car.speed;
|
|
2139
|
+
const previousHeading = car.heading;
|
|
2140
|
+
car.previousX = car.x;
|
|
2141
|
+
car.previousY = car.y;
|
|
2142
|
+
car.previousHeading = car.heading;
|
|
2143
|
+
car.x = point.x;
|
|
2144
|
+
car.y = point.y;
|
|
2145
|
+
car.heading = point.heading ?? car.heading;
|
|
2146
|
+
car.speed = targetSpeed;
|
|
2147
|
+
car.throttle = limiterActive ? 0.28 : 0.46;
|
|
2148
|
+
car.brake = targetSpeed < previousSpeed ? 0.4 : 0;
|
|
2149
|
+
car.yawRate = normalizeAngle(car.heading - previousHeading) / Math.max(delta, 1e-6);
|
|
2150
|
+
car.steeringAngle = clamp(
|
|
2151
|
+
Math.atan((car.yawRate * VEHICLE_LIMITS.wheelbase) / Math.max(car.speed, 1)),
|
|
2152
|
+
-VEHICLE_LIMITS.maxSteer,
|
|
2153
|
+
VEHICLE_LIMITS.maxSteer,
|
|
2154
|
+
);
|
|
2155
|
+
car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
|
|
2156
|
+
applyWheelSurfaceState(car, this.track);
|
|
2157
|
+
stop.routeProgress = nextProgress;
|
|
2158
|
+
const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
|
|
2159
|
+
car.raceDistance = stop.routeStartRaceDistance +
|
|
2160
|
+
(stop.routeEndRaceDistance - stop.routeStartRaceDistance) * routeAmount;
|
|
2161
|
+
car.progress = car.trackState.distance;
|
|
2162
|
+
car.lap = this.computeLap(car.raceDistance);
|
|
2163
|
+
const remainingAfter = Math.max(0, route.length - stop.routeProgress);
|
|
2164
|
+
if (stop.status === 'entering') {
|
|
2165
|
+
const captureDistance = stop.queueingForService ? PIT_QUEUE_CAPTURE_DISTANCE : PIT_ENTRY_BOX_CAPTURE_DISTANCE;
|
|
2166
|
+
const captureSpeed = stop.queueingForService ? PIT_QUEUE_CAPTURE_SPEED : PIT_BOX_STOP_SPEED;
|
|
2167
|
+
return remainingAfter <= captureDistance &&
|
|
2168
|
+
pointDistance(car, routeEnd) <= captureDistance &&
|
|
2169
|
+
car.speed <= captureSpeed;
|
|
2170
|
+
}
|
|
2171
|
+
return remainingAfter <= PIT_ROUTE_FINISH_DISTANCE;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
advancePitStopCar(car, delta) {
|
|
2175
|
+
const pitStops = this.rules.modules?.pitStops;
|
|
2176
|
+
const stop = car.pitStop;
|
|
2177
|
+
if (!pitStops?.enabled || !stop) return false;
|
|
2178
|
+
if (stop.status === 'pending' || stop.status === 'completed') this.updateAutomaticPitIntent(car);
|
|
2179
|
+
if (stop.status === 'completed') return false;
|
|
2180
|
+
if (stop.status === 'pending' && !this.shouldStartPitStop(car)) return false;
|
|
2181
|
+
if (stop.status === 'pending') this.startPitStop(car);
|
|
2182
|
+
|
|
2183
|
+
if (stop.status === 'entering') {
|
|
2184
|
+
const finishedRoute = this.applyPitRoutePosition(car, delta);
|
|
2185
|
+
const box = this.getPitStopBox(stop);
|
|
2186
|
+
if (finishedRoute && box) {
|
|
2187
|
+
if (stop.queueingForService) {
|
|
2188
|
+
if (!this.isPitServiceBusy(car, box) && !this.isPitServiceQueueOccupied(car, box)) {
|
|
2189
|
+
this.releasePitQueue(car, box, { fromCurrent: true });
|
|
2190
|
+
} else {
|
|
2191
|
+
this.beginPitQueue(car, box);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
else this.beginPitService(car, box);
|
|
2195
|
+
}
|
|
2196
|
+
car.contactCooldown = Math.max(0, car.contactCooldown - delta);
|
|
2197
|
+
return true;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
if (stop.status === 'queued') {
|
|
2201
|
+
const box = this.getPitStopBox(stop);
|
|
2202
|
+
if (!box) return true;
|
|
2203
|
+
car.previousX = car.x;
|
|
2204
|
+
car.previousY = car.y;
|
|
2205
|
+
car.previousHeading = car.heading;
|
|
2206
|
+
car.x = box.queuePoint.x;
|
|
2207
|
+
car.y = box.queuePoint.y;
|
|
2208
|
+
car.heading = this.track.pitLane.mainLane.heading;
|
|
2209
|
+
car.speed = 0;
|
|
2210
|
+
car.throttle = 0;
|
|
2211
|
+
car.brake = 1;
|
|
2212
|
+
applyWheelSurfaceState(car, this.track);
|
|
2213
|
+
car.progress = car.trackState.distance;
|
|
2214
|
+
car.raceDistance = this.getPitBoxRaceDistance(stop, {
|
|
2215
|
+
...box,
|
|
2216
|
+
distanceAlongLane: box.queueDistanceAlongLane ?? box.distanceAlongLane,
|
|
2217
|
+
});
|
|
2218
|
+
if (!this.isPitServiceBusy(car, box)) this.releasePitQueue(car, box);
|
|
2219
|
+
return true;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
if (stop.status === 'servicing') {
|
|
2223
|
+
const box = this.getPitStopBox(stop);
|
|
2224
|
+
if (!box) return true;
|
|
2225
|
+
if (stop.phase === 'penalty') {
|
|
2226
|
+
stop.penaltyServiceRemaining = Math.max(0, (stop.penaltyServiceRemaining ?? 0) - delta);
|
|
2227
|
+
stop.serviceRemaining = stop.penaltyServiceRemaining;
|
|
2228
|
+
} else {
|
|
2229
|
+
stop.serviceRemaining = Math.max(0, (stop.serviceRemaining ?? 0) - delta);
|
|
2230
|
+
}
|
|
2231
|
+
car.previousX = car.x;
|
|
2232
|
+
car.previousY = car.y;
|
|
2233
|
+
car.previousHeading = car.heading;
|
|
2234
|
+
car.x = box.center.x;
|
|
2235
|
+
car.y = box.center.y;
|
|
2236
|
+
car.heading = this.track.pitLane.mainLane.heading;
|
|
2237
|
+
car.speed = 0;
|
|
2238
|
+
car.throttle = 0;
|
|
2239
|
+
car.brake = 1;
|
|
2240
|
+
applyWheelSurfaceState(car, this.track);
|
|
2241
|
+
car.progress = car.trackState.distance;
|
|
2242
|
+
car.raceDistance = this.getPitBoxRaceDistance(stop, box);
|
|
2243
|
+
if (stop.phase === 'penalty' && stop.penaltyServiceRemaining <= 0) {
|
|
2244
|
+
this.completePitPenaltyService(car);
|
|
2245
|
+
} else if (stop.serviceRemaining <= 0) {
|
|
2246
|
+
this.completePitService(car);
|
|
2247
|
+
}
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
if (stop.status === 'exiting') {
|
|
2252
|
+
const finishedRoute = this.applyPitRoutePosition(car, delta);
|
|
2253
|
+
if (finishedRoute) this.finishPitExit(car);
|
|
2254
|
+
car.contactCooldown = Math.max(0, car.contactCooldown - delta);
|
|
2255
|
+
return true;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
return false;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
step(dt) {
|
|
2262
|
+
const delta = clamp(dt, 0, 1 / 20);
|
|
694
2263
|
if (!Number.isFinite(delta) || delta <= 0) return;
|
|
695
2264
|
|
|
696
2265
|
this.time += delta;
|
|
@@ -704,6 +2273,24 @@ export class F1RaceSimulation {
|
|
|
704
2273
|
return;
|
|
705
2274
|
}
|
|
706
2275
|
|
|
2276
|
+
if (this.raceControl.redFlag) {
|
|
2277
|
+
this.cars.forEach((car) => {
|
|
2278
|
+
car.previousX = car.x;
|
|
2279
|
+
car.previousY = car.y;
|
|
2280
|
+
car.previousHeading = car.heading;
|
|
2281
|
+
car.speed = 0;
|
|
2282
|
+
car.throttle = 0;
|
|
2283
|
+
car.brake = 1;
|
|
2284
|
+
car.drsActive = false;
|
|
2285
|
+
car.drsEligible = false;
|
|
2286
|
+
car.drsZoneId = null;
|
|
2287
|
+
car.drsZoneEnabled = false;
|
|
2288
|
+
car.canAttack = false;
|
|
2289
|
+
});
|
|
2290
|
+
this.recalculateRaceState({ updateDrs: false });
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
707
2294
|
this.updateSafetyCar(delta);
|
|
708
2295
|
|
|
709
2296
|
const orderedCars = this.orderedCars();
|
|
@@ -713,6 +2300,7 @@ export class F1RaceSimulation {
|
|
|
713
2300
|
car.previousY = car.y;
|
|
714
2301
|
car.previousHeading = car.heading;
|
|
715
2302
|
car.previousProgress = car.progress;
|
|
2303
|
+
if (this.advancePitStopCar(car, delta)) return;
|
|
716
2304
|
const controls = decideDriverControls({
|
|
717
2305
|
car,
|
|
718
2306
|
orderIndex: index,
|
|
@@ -725,11 +2313,169 @@ export class F1RaceSimulation {
|
|
|
725
2313
|
|
|
726
2314
|
this.resolveCollisions();
|
|
727
2315
|
this.recalculateRaceState();
|
|
728
|
-
this.
|
|
2316
|
+
this.reviewTrackLimits();
|
|
2317
|
+
this.reviewPitLaneSpeeding();
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
recordPenalty(penalty) {
|
|
2321
|
+
const car = this.cars.find((item) => item.id === penalty.driverId);
|
|
2322
|
+
const entry = createPenaltyRecord({
|
|
2323
|
+
sequence: this.nextPenaltyId,
|
|
2324
|
+
time: this.time,
|
|
2325
|
+
lap: this.computeLap(car?.raceDistance ?? 0),
|
|
2326
|
+
penalty,
|
|
2327
|
+
});
|
|
2328
|
+
this.nextPenaltyId += 1;
|
|
2329
|
+
this.penalties.push(entry);
|
|
2330
|
+
if (entry.gridDrop > 0 && this.raceControl.mode === 'pre-start') {
|
|
2331
|
+
this.applyGridDrop(entry.driverId, entry.gridDrop);
|
|
2332
|
+
}
|
|
2333
|
+
this.events.unshift(createPenaltyEvent(entry));
|
|
2334
|
+
return entry;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
servePenalty(penaltyId) {
|
|
2338
|
+
const penalty = this.penalties.find((entry) => entry.id === penaltyId);
|
|
2339
|
+
const result = servePenaltyRecord(penalty, this.time);
|
|
2340
|
+
if (result) {
|
|
2341
|
+
this.events.unshift({
|
|
2342
|
+
type: 'penalty-served',
|
|
2343
|
+
at: this.time,
|
|
2344
|
+
penaltyId: result.id,
|
|
2345
|
+
driverId: result.driverId,
|
|
2346
|
+
serviceType: result.serviceType,
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
return result;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
cancelPenalty(penaltyId) {
|
|
2353
|
+
const penalty = this.penalties.find((entry) => entry.id === penaltyId);
|
|
2354
|
+
const result = cancelPenaltyRecord(penalty, this.time);
|
|
2355
|
+
if (result) {
|
|
2356
|
+
this.events.unshift({
|
|
2357
|
+
type: 'penalty-cancelled',
|
|
2358
|
+
at: this.time,
|
|
2359
|
+
penaltyId: result.id,
|
|
2360
|
+
driverId: result.driverId,
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
return result;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
getDriverPenaltySeconds(driverId) {
|
|
2367
|
+
return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).seconds;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
getDriverPositionDrop(driverId) {
|
|
2371
|
+
return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).positionDrop;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
isDriverDisqualified(driverId) {
|
|
2375
|
+
return getPenaltyStats(this.getPenaltyStatsByDriver(), driverId).disqualified;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
getPenaltyStatsByDriver() {
|
|
2379
|
+
return buildPenaltyStatsByDriver(this.penalties);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
applyGridDrop(driverId, positions) {
|
|
2383
|
+
const drop = Math.max(0, Math.floor(Number(positions) || 0));
|
|
2384
|
+
if (drop <= 0) return;
|
|
2385
|
+
|
|
2386
|
+
const ordered = [...this.cars].sort((left, right) => {
|
|
2387
|
+
const delta = right.gridDistance - left.gridDistance;
|
|
2388
|
+
return delta === 0 ? left.index - right.index : delta;
|
|
2389
|
+
});
|
|
2390
|
+
const currentIndex = ordered.findIndex((car) => car.id === driverId);
|
|
2391
|
+
if (currentIndex < 0) return;
|
|
2392
|
+
const [car] = ordered.splice(currentIndex, 1);
|
|
2393
|
+
ordered.splice(Math.min(ordered.length, currentIndex + drop), 0, car);
|
|
2394
|
+
|
|
2395
|
+
ordered.forEach((entry, index) => {
|
|
2396
|
+
const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart: true });
|
|
2397
|
+
const gridPoint = pointAt(this.track, gridDistance);
|
|
2398
|
+
const position = offsetTrackPoint(gridPoint, gridOffset);
|
|
2399
|
+
entry.gridDistance = gridDistance;
|
|
2400
|
+
entry.gridOffset = gridOffset;
|
|
2401
|
+
entry.rank = index + 1;
|
|
2402
|
+
if (entry.gridLocked) {
|
|
2403
|
+
entry.x = position.x;
|
|
2404
|
+
entry.y = position.y;
|
|
2405
|
+
entry.previousX = position.x;
|
|
2406
|
+
entry.previousY = position.y;
|
|
2407
|
+
entry.heading = gridPoint.heading;
|
|
2408
|
+
entry.previousHeading = gridPoint.heading;
|
|
2409
|
+
entry.progress = gridPoint.distance;
|
|
2410
|
+
entry.raceDistance = gridDistance;
|
|
2411
|
+
applyWheelSurfaceState(entry, this.track);
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
reviewCollision(first, second, collision) {
|
|
2417
|
+
const rule = getPenaltyRule(this.rules, 'collision');
|
|
2418
|
+
calculateCollisionPenalties({ first, second, collision, rule })
|
|
2419
|
+
.forEach((penalty) => this.recordPenalty(penalty));
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
reviewTireRequirement(car) {
|
|
2423
|
+
if (this.stewardState.tireRequirement[car.id]) return;
|
|
2424
|
+
const rule = getPenaltyRule(this.rules, 'tireRequirement');
|
|
2425
|
+
const penalty = calculateTireRequirementPenalty({
|
|
2426
|
+
car,
|
|
2427
|
+
tireStrategy: this.rules.modules?.tireStrategy,
|
|
2428
|
+
rule,
|
|
2429
|
+
});
|
|
2430
|
+
this.stewardState.tireRequirement[car.id] = true;
|
|
2431
|
+
if (penalty) this.recordPenalty(penalty);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
reviewTrackLimits() {
|
|
2435
|
+
const rule = getPenaltyRule(this.rules, 'trackLimits');
|
|
2436
|
+
if (!rule) return;
|
|
2437
|
+
|
|
2438
|
+
this.cars.forEach((car) => {
|
|
2439
|
+
const currentState = this.stewardState.trackLimits[car.id];
|
|
2440
|
+
if (isLegallyInsidePitLaneForTrackLimits(car) && !currentState?.active) {
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
const review = calculateTrackLimitReview({
|
|
2444
|
+
car,
|
|
2445
|
+
rule,
|
|
2446
|
+
track: this.track,
|
|
2447
|
+
stewardState: currentState,
|
|
2448
|
+
});
|
|
2449
|
+
this.stewardState.trackLimits[car.id] = review.nextState;
|
|
2450
|
+
if (review.event) this.events.unshift({ ...review.event, at: this.time });
|
|
2451
|
+
if (review.penalty) this.recordPenalty(review.penalty);
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
reviewPitLaneSpeeding() {
|
|
2456
|
+
const rule = getPenaltyRule(this.rules, 'pitLaneSpeeding');
|
|
2457
|
+
if (!rule) return;
|
|
2458
|
+
|
|
2459
|
+
this.cars.forEach((car) => {
|
|
2460
|
+
const currentState = this.stewardState.pitLaneSpeeding[car.id];
|
|
2461
|
+
const review = calculatePitLaneSpeedingReview({
|
|
2462
|
+
car: {
|
|
2463
|
+
...car,
|
|
2464
|
+
speedKph: simSpeedToKph(car.speed),
|
|
2465
|
+
},
|
|
2466
|
+
rule,
|
|
2467
|
+
stewardState: currentState,
|
|
2468
|
+
});
|
|
2469
|
+
this.stewardState.pitLaneSpeeding[car.id] = review.nextState;
|
|
2470
|
+
if (review.event) this.events.unshift({ ...review.event, at: this.time });
|
|
2471
|
+
if (review.penalty) this.recordPenalty(review.penalty);
|
|
2472
|
+
});
|
|
729
2473
|
}
|
|
730
2474
|
|
|
731
2475
|
snapshot() {
|
|
732
2476
|
const ordered = this.orderedCars();
|
|
2477
|
+
const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
|
|
2478
|
+
const penaltyStats = this.getPenaltyStatsByDriver();
|
|
733
2479
|
return {
|
|
734
2480
|
time: this.time,
|
|
735
2481
|
world: WORLD,
|
|
@@ -737,6 +2483,9 @@ export class F1RaceSimulation {
|
|
|
737
2483
|
totalLaps: this.totalLaps,
|
|
738
2484
|
raceControl: {
|
|
739
2485
|
mode: this.raceControl.mode,
|
|
2486
|
+
redFlag: Boolean(this.raceControl.redFlag),
|
|
2487
|
+
pitLaneOpen: pitLaneStatus.open,
|
|
2488
|
+
pitLaneStatus,
|
|
740
2489
|
finished: this.raceControl.finished,
|
|
741
2490
|
finishedAt: this.raceControl.finishedAt,
|
|
742
2491
|
winner: this.getRaceWinnerSnapshot(),
|
|
@@ -747,23 +2496,99 @@ export class F1RaceSimulation {
|
|
|
747
2496
|
(this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
|
|
748
2497
|
},
|
|
749
2498
|
},
|
|
2499
|
+
pitLaneStatus,
|
|
750
2500
|
safetyCar: { ...this.safetyCar },
|
|
751
2501
|
rules: this.rules,
|
|
752
2502
|
events: [...this.events],
|
|
753
|
-
|
|
2503
|
+
penalties: this.penalties.map(serializePenalty),
|
|
2504
|
+
cars: ordered.map((car, index) => serializeCar(car, index + 1, getPenaltyStats(penaltyStats, car.id).seconds)),
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
snapshotRender() {
|
|
2509
|
+
const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
|
|
2510
|
+
return {
|
|
2511
|
+
time: this.time,
|
|
2512
|
+
world: WORLD,
|
|
2513
|
+
track: this.track,
|
|
2514
|
+
totalLaps: this.totalLaps,
|
|
2515
|
+
raceControl: {
|
|
2516
|
+
mode: this.raceControl.mode,
|
|
2517
|
+
redFlag: Boolean(this.raceControl.redFlag),
|
|
2518
|
+
pitLaneOpen: pitLaneStatus.open,
|
|
2519
|
+
pitLaneStatus,
|
|
2520
|
+
finished: this.raceControl.finished,
|
|
2521
|
+
start: {
|
|
2522
|
+
...this.raceControl.start,
|
|
2523
|
+
visible: this.raceControl.mode === 'pre-start' ||
|
|
2524
|
+
(this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
|
|
2525
|
+
},
|
|
2526
|
+
},
|
|
2527
|
+
pitLaneStatus,
|
|
2528
|
+
safetyCar: { ...this.safetyCar },
|
|
2529
|
+
cars: this.orderedCars().map(serializeRenderCar),
|
|
754
2530
|
};
|
|
755
2531
|
}
|
|
756
2532
|
|
|
2533
|
+
snapshotObservation() {
|
|
2534
|
+
const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
|
|
2535
|
+
return {
|
|
2536
|
+
time: this.time,
|
|
2537
|
+
world: WORLD,
|
|
2538
|
+
track: this.track,
|
|
2539
|
+
totalLaps: this.totalLaps,
|
|
2540
|
+
raceControl: {
|
|
2541
|
+
mode: this.raceControl.mode,
|
|
2542
|
+
redFlag: Boolean(this.raceControl.redFlag),
|
|
2543
|
+
pitLaneOpen: pitLaneStatus.open,
|
|
2544
|
+
pitLaneStatus,
|
|
2545
|
+
finished: this.raceControl.finished,
|
|
2546
|
+
},
|
|
2547
|
+
pitLaneStatus,
|
|
2548
|
+
safetyCar: { ...this.safetyCar },
|
|
2549
|
+
events: [...this.events],
|
|
2550
|
+
cars: this.orderedCars().map((car, index) => serializeObservationCar(car, index + 1)),
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
consumeStepEvents() {
|
|
2555
|
+
return [...this.events];
|
|
2556
|
+
}
|
|
2557
|
+
|
|
757
2558
|
orderedCars() {
|
|
2559
|
+
const sortLive = (cars) => [...cars].sort((a, b) => {
|
|
2560
|
+
const delta = b.raceDistance - a.raceDistance;
|
|
2561
|
+
return delta === 0 ? a.index - b.index : delta;
|
|
2562
|
+
});
|
|
2563
|
+
const byId = new Map(this.cars.map((car) => [car.id, car]));
|
|
2564
|
+
|
|
2565
|
+
if (this.raceControl.finished && this.raceControl.classification?.length) {
|
|
2566
|
+
const classified = this.raceControl.classification.map((entry) => byId.get(entry.id)).filter(Boolean);
|
|
2567
|
+
const classifiedIds = new Set(classified.map((car) => car.id));
|
|
2568
|
+
return [
|
|
2569
|
+
...classified,
|
|
2570
|
+
...sortLive(this.cars.filter((car) => !classifiedIds.has(car.id))),
|
|
2571
|
+
];
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
if (this.raceControl.finishOrder?.length) {
|
|
2575
|
+
const finished = this.raceControl.finishOrder.map((id) => byId.get(id)).filter(Boolean);
|
|
2576
|
+
const finishedIds = new Set(finished.map((car) => car.id));
|
|
2577
|
+
const missedFinished = this.cars
|
|
2578
|
+
.filter((car) => car.finished && !finishedIds.has(car.id))
|
|
2579
|
+
.sort((a, b) => {
|
|
2580
|
+
const delta = (a.finishRank ?? Infinity) - (b.finishRank ?? Infinity);
|
|
2581
|
+
return delta === 0 ? a.index - b.index : delta;
|
|
2582
|
+
});
|
|
2583
|
+
const running = sortLive(this.cars.filter((car) => !finishedIds.has(car.id) && !car.finished));
|
|
2584
|
+
return [...finished, ...missedFinished, ...running];
|
|
2585
|
+
}
|
|
2586
|
+
|
|
758
2587
|
if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
|
|
759
|
-
const byId = new Map(this.cars.map((car) => [car.id, car]));
|
|
760
2588
|
return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
|
|
761
2589
|
}
|
|
762
2590
|
|
|
763
|
-
return
|
|
764
|
-
const delta = b.raceDistance - a.raceDistance;
|
|
765
|
-
return delta === 0 ? a.index - b.index : delta;
|
|
766
|
-
});
|
|
2591
|
+
return sortLive(this.cars);
|
|
767
2592
|
}
|
|
768
2593
|
|
|
769
2594
|
driverRaceContext(orderedCars = this.orderedCars()) {
|
|
@@ -826,9 +2651,10 @@ export class F1RaceSimulation {
|
|
|
826
2651
|
const wasGridLocked = car.gridLocked;
|
|
827
2652
|
car.gridLocked = false;
|
|
828
2653
|
const state = nearestTrackState(this.track, car, car.gridDistance);
|
|
829
|
-
car.trackState = state;
|
|
830
2654
|
car.progress = state.distance;
|
|
2655
|
+
applyWheelSurfaceState(car, this.track);
|
|
831
2656
|
if (wasGridLocked) car.raceDistance = car.gridDistance;
|
|
2657
|
+
if (wasGridLocked) car.desiredOffset = 0;
|
|
832
2658
|
car.previousX = car.x;
|
|
833
2659
|
car.previousY = car.y;
|
|
834
2660
|
car.previousHeading = car.heading;
|
|
@@ -854,7 +2680,7 @@ export class F1RaceSimulation {
|
|
|
854
2680
|
car.turnRadius = Infinity;
|
|
855
2681
|
car.progress = gridPoint.distance;
|
|
856
2682
|
car.raceDistance = car.gridDistance;
|
|
857
|
-
car
|
|
2683
|
+
applyWheelSurfaceState(car, this.track);
|
|
858
2684
|
});
|
|
859
2685
|
}
|
|
860
2686
|
|
|
@@ -862,7 +2688,9 @@ export class F1RaceSimulation {
|
|
|
862
2688
|
if (!this.safetyCar.deployed) return;
|
|
863
2689
|
const leader = this.orderedCars()[0];
|
|
864
2690
|
const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
|
|
865
|
-
const progress =
|
|
2691
|
+
const progress = this.safetyCar.progress < targetProgress
|
|
2692
|
+
? Math.min(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress)
|
|
2693
|
+
: targetProgress;
|
|
866
2694
|
this.moveSafetyCarTo(progress);
|
|
867
2695
|
}
|
|
868
2696
|
|
|
@@ -879,10 +2707,14 @@ export class F1RaceSimulation {
|
|
|
879
2707
|
|
|
880
2708
|
applyRunoffResponse(car) {
|
|
881
2709
|
const state = nearestTrackState(this.track, car, car.progress);
|
|
2710
|
+
if (state.inPitLane) {
|
|
2711
|
+
applyWheelSurfaceState(car, this.track, { centerState: state });
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
882
2714
|
const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
|
|
883
2715
|
const overshoot = Math.abs(state.signedOffset) - signedLimit;
|
|
884
2716
|
if (overshoot <= 0) {
|
|
885
|
-
car.
|
|
2717
|
+
applyWheelSurfaceState(car, this.track, { centerState: state });
|
|
886
2718
|
return;
|
|
887
2719
|
}
|
|
888
2720
|
|
|
@@ -891,7 +2723,7 @@ export class F1RaceSimulation {
|
|
|
891
2723
|
car.y -= state.normalY * side * overshoot;
|
|
892
2724
|
car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
|
|
893
2725
|
car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
|
|
894
|
-
car
|
|
2726
|
+
applyWheelSurfaceState(car, this.track);
|
|
895
2727
|
}
|
|
896
2728
|
|
|
897
2729
|
recalculateRaceState({ updateDrs = true } = {}) {
|
|
@@ -899,7 +2731,7 @@ export class F1RaceSimulation {
|
|
|
899
2731
|
const previousRaceDistance = car.raceDistance;
|
|
900
2732
|
if (car.gridLocked) {
|
|
901
2733
|
const gridPoint = pointAt(this.track, car.gridDistance);
|
|
902
|
-
car
|
|
2734
|
+
applyWheelSurfaceState(car, this.track);
|
|
903
2735
|
car.progress = gridPoint.distance;
|
|
904
2736
|
car.raceDistance = car.gridDistance;
|
|
905
2737
|
car.lap = 1;
|
|
@@ -907,7 +2739,7 @@ export class F1RaceSimulation {
|
|
|
907
2739
|
return;
|
|
908
2740
|
}
|
|
909
2741
|
|
|
910
|
-
car
|
|
2742
|
+
applyWheelSurfaceState(car, this.track);
|
|
911
2743
|
const previousProgress = car.progress ?? car.trackState.distance;
|
|
912
2744
|
const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
|
|
913
2745
|
car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
|
|
@@ -917,20 +2749,41 @@ export class F1RaceSimulation {
|
|
|
917
2749
|
});
|
|
918
2750
|
|
|
919
2751
|
updateSectorPerformance(this.cars);
|
|
920
|
-
this.cars.forEach((car) =>
|
|
2752
|
+
this.cars.forEach((car) => {
|
|
2753
|
+
recordTimingSample(car, this.time);
|
|
2754
|
+
recordTimingLineCrossings(car, car.previousRaceDistanceForTiming, this.time, this.track);
|
|
2755
|
+
car.previousRaceDistanceForTiming = car.raceDistance;
|
|
2756
|
+
});
|
|
921
2757
|
|
|
922
2758
|
const ordered = this.orderedCars();
|
|
2759
|
+
const leader = ordered[0];
|
|
923
2760
|
ordered.forEach((car, index) => {
|
|
924
2761
|
const ahead = ordered[index - 1];
|
|
2762
|
+
const drsReference = this.getDrsReferenceCar(car);
|
|
925
2763
|
const gap = ahead ? ahead.raceDistance - car.raceDistance : Infinity;
|
|
2764
|
+
const intervalAheadLaps = ahead ? wholeLapGap(ahead.raceDistance, car.raceDistance, this.track.length) : 0;
|
|
2765
|
+
const leaderGapLaps = leader ? wholeLapGap(leader.raceDistance, car.raceDistance, this.track.length) : 0;
|
|
2766
|
+
const activePitStop = this.isCarInActivePitStop(car);
|
|
926
2767
|
car.rank = index + 1;
|
|
927
2768
|
car.gapAhead = gap;
|
|
928
|
-
car.
|
|
2769
|
+
car.gapAheadLaps = intervalAheadLaps;
|
|
2770
|
+
car.intervalAheadLaps = intervalAheadLaps;
|
|
2771
|
+
car.leaderGapLaps = leaderGapLaps;
|
|
2772
|
+
car.gapAheadSeconds = Number.isFinite(gap) && intervalAheadLaps === 0
|
|
2773
|
+
? estimateGapAheadSeconds(ahead, car, this.time, this.track)
|
|
2774
|
+
: Infinity;
|
|
929
2775
|
car.intervalAheadSeconds = car.gapAheadSeconds;
|
|
930
|
-
car.leaderGapSeconds =
|
|
931
|
-
|
|
2776
|
+
car.leaderGapSeconds = leaderGapLaps > 0
|
|
2777
|
+
? Infinity
|
|
2778
|
+
: (leader && leader !== car ? estimateGapAheadSeconds(leader, car, this.time, this.track) : 0);
|
|
2779
|
+
car.canAttack = !this.safetyCar.deployed && !car.finished && !activePitStop;
|
|
932
2780
|
car.aggression = this.computeAggression(car, index);
|
|
933
|
-
if (
|
|
2781
|
+
if (activePitStop) {
|
|
2782
|
+
car.drsEligible = false;
|
|
2783
|
+
car.drsActive = false;
|
|
2784
|
+
car.drsZoneId = null;
|
|
2785
|
+
car.drsZoneEnabled = false;
|
|
2786
|
+
} else if (updateDrs) this.updateDrsLatch(car, drsReference, Boolean(drsReference));
|
|
934
2787
|
});
|
|
935
2788
|
this.evaluateRaceFinish();
|
|
936
2789
|
}
|
|
@@ -946,7 +2799,8 @@ export class F1RaceSimulation {
|
|
|
946
2799
|
newlyFinished.forEach((car) => {
|
|
947
2800
|
car.finished = true;
|
|
948
2801
|
car.finishTime = this.time;
|
|
949
|
-
car.
|
|
2802
|
+
car.finishRank = this.raceControl.finishOrder.length + 1;
|
|
2803
|
+
car.classifiedRank = car.finishRank;
|
|
950
2804
|
this.raceControl.finishOrder.push(car.id);
|
|
951
2805
|
if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
|
|
952
2806
|
car.drsActive = false;
|
|
@@ -958,14 +2812,17 @@ export class F1RaceSimulation {
|
|
|
958
2812
|
type: 'car-finish',
|
|
959
2813
|
at: this.time,
|
|
960
2814
|
carId: car.id,
|
|
961
|
-
rank: car.
|
|
2815
|
+
rank: car.finishRank,
|
|
962
2816
|
winnerId: this.raceControl.winnerId,
|
|
963
2817
|
});
|
|
2818
|
+
this.reviewTireRequirement(car);
|
|
964
2819
|
});
|
|
965
2820
|
|
|
966
2821
|
if (!this.cars.every((car) => car.finished)) return;
|
|
967
2822
|
|
|
2823
|
+
this.applyOutstandingServicePenalties();
|
|
968
2824
|
const classification = this.buildClassificationFromFinishOrder();
|
|
2825
|
+
this.raceControl.winnerId = classification[0]?.id ?? this.raceControl.winnerId;
|
|
969
2826
|
this.raceControl.mode = 'safety-car';
|
|
970
2827
|
this.raceControl.finished = true;
|
|
971
2828
|
this.raceControl.finishedAt = this.time;
|
|
@@ -995,32 +2852,88 @@ export class F1RaceSimulation {
|
|
|
995
2852
|
});
|
|
996
2853
|
}
|
|
997
2854
|
|
|
2855
|
+
applyOutstandingServicePenalties() {
|
|
2856
|
+
this.penalties.forEach((penalty) => {
|
|
2857
|
+
const beforeStatus = penalty.status;
|
|
2858
|
+
applyUnservedServicePenalty(penalty, this.time);
|
|
2859
|
+
if (beforeStatus !== penalty.status && penalty.unserved) {
|
|
2860
|
+
this.events.unshift({
|
|
2861
|
+
type: 'penalty-applied',
|
|
2862
|
+
at: this.time,
|
|
2863
|
+
penaltyId: penalty.id,
|
|
2864
|
+
driverId: penalty.driverId,
|
|
2865
|
+
serviceType: penalty.serviceType,
|
|
2866
|
+
penaltySeconds: penalty.penaltySeconds,
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
|
|
998
2872
|
buildClassificationFromFinishOrder() {
|
|
999
2873
|
const byId = new Map(this.cars.map((car) => [car.id, car]));
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
.
|
|
1003
|
-
|
|
2874
|
+
const penaltyStats = this.getPenaltyStatsByDriver();
|
|
2875
|
+
const orderedByAdjustedTime = this.raceControl.finishOrder
|
|
2876
|
+
.map((id, finishOrderIndex) => ({ car: byId.get(id), finishOrderIndex }))
|
|
2877
|
+
.filter((entry) => Boolean(entry.car))
|
|
2878
|
+
.sort((left, right) => {
|
|
2879
|
+
const leftTime = (left.car.finishTime ?? Infinity) + getPenaltyStats(penaltyStats, left.car.id).seconds;
|
|
2880
|
+
const rightTime = (right.car.finishTime ?? Infinity) + getPenaltyStats(penaltyStats, right.car.id).seconds;
|
|
2881
|
+
return leftTime === rightTime ? left.finishOrderIndex - right.finishOrderIndex : leftTime - rightTime;
|
|
2882
|
+
})
|
|
2883
|
+
.map((entry) => entry.car);
|
|
2884
|
+
const ordered = this.applyClassificationConsequences(orderedByAdjustedTime, penaltyStats);
|
|
2885
|
+
return this.buildClassification(ordered, penaltyStats);
|
|
1004
2886
|
}
|
|
1005
2887
|
|
|
1006
|
-
|
|
2888
|
+
applyClassificationConsequences(ordered, penaltyStats = this.getPenaltyStatsByDriver()) {
|
|
2889
|
+
const classified = ordered.filter((car) => !getPenaltyStats(penaltyStats, car.id).disqualified);
|
|
2890
|
+
ordered.forEach((car) => {
|
|
2891
|
+
const stats = getPenaltyStats(penaltyStats, car.id);
|
|
2892
|
+
if (stats.disqualified) return;
|
|
2893
|
+
const drop = stats.positionDrop;
|
|
2894
|
+
if (drop <= 0) return;
|
|
2895
|
+
const currentIndex = classified.findIndex((entry) => entry.id === car.id);
|
|
2896
|
+
if (currentIndex < 0) return;
|
|
2897
|
+
const [entry] = classified.splice(currentIndex, 1);
|
|
2898
|
+
classified.splice(Math.min(classified.length, currentIndex + drop), 0, entry);
|
|
2899
|
+
});
|
|
2900
|
+
return [
|
|
2901
|
+
...classified,
|
|
2902
|
+
...ordered.filter((car) => getPenaltyStats(penaltyStats, car.id).disqualified),
|
|
2903
|
+
];
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
buildClassification(ordered = this.orderedCars(), penaltyStats = this.getPenaltyStatsByDriver()) {
|
|
1007
2907
|
const leaderDistance = ordered[0]?.raceDistance ?? 0;
|
|
1008
|
-
return ordered.map((car, index) =>
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
2908
|
+
return ordered.map((car, index) => {
|
|
2909
|
+
const finishTime = car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null);
|
|
2910
|
+
const stats = getPenaltyStats(penaltyStats, car.id);
|
|
2911
|
+
const penaltySeconds = stats.seconds;
|
|
2912
|
+
const positionDrop = stats.positionDrop;
|
|
2913
|
+
const disqualified = stats.disqualified;
|
|
2914
|
+
return {
|
|
2915
|
+
id: car.id,
|
|
2916
|
+
code: car.code,
|
|
2917
|
+
timingCode: car.timingCode,
|
|
2918
|
+
name: car.name,
|
|
2919
|
+
rank: index + 1,
|
|
2920
|
+
raceDistance: car.raceDistance,
|
|
2921
|
+
distanceMeters: simUnitsToMeters(car.raceDistance),
|
|
2922
|
+
lap: this.computeLap(car.raceDistance),
|
|
2923
|
+
lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
|
|
2924
|
+
gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
|
|
2925
|
+
gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
|
|
2926
|
+
intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
|
|
2927
|
+
gapLaps: index === 0 ? 0 : car.leaderGapLaps,
|
|
2928
|
+
intervalLaps: index === 0 ? 0 : car.intervalAheadLaps,
|
|
2929
|
+
finished: car.raceDistance >= this.finishDistance,
|
|
2930
|
+
finishTime,
|
|
2931
|
+
penaltySeconds,
|
|
2932
|
+
adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
|
|
2933
|
+
positionDrop,
|
|
2934
|
+
disqualified,
|
|
2935
|
+
};
|
|
2936
|
+
});
|
|
1024
2937
|
}
|
|
1025
2938
|
|
|
1026
2939
|
getRaceWinnerSnapshot() {
|
|
@@ -1028,10 +2941,21 @@ export class F1RaceSimulation {
|
|
|
1028
2941
|
const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
|
|
1029
2942
|
if (!car) return null;
|
|
1030
2943
|
const rank = car.classifiedRank ?? car.rank ?? 1;
|
|
1031
|
-
return serializeCar(car, rank);
|
|
2944
|
+
return serializeCar(car, rank, getPenaltyStats(this.getPenaltyStatsByDriver(), car.id).seconds);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
getDrsReferenceCar(car) {
|
|
2948
|
+
let closest = null;
|
|
2949
|
+
this.cars.forEach((candidate) => {
|
|
2950
|
+
if (candidate === car || candidate.finished || this.isCarInActivePitStop(candidate)) return;
|
|
2951
|
+
const delta = distanceForward(car.progress ?? car.raceDistance ?? 0, candidate.progress ?? candidate.raceDistance ?? 0, this.track.length);
|
|
2952
|
+
if (delta <= 0 || delta > this.track.length / 2) return;
|
|
2953
|
+
if (!closest || delta < closest.delta) closest = { car: candidate, delta };
|
|
2954
|
+
});
|
|
2955
|
+
return closest?.car ?? null;
|
|
1032
2956
|
}
|
|
1033
2957
|
|
|
1034
|
-
updateDrsLatch(car, ahead,
|
|
2958
|
+
updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
|
|
1035
2959
|
if (this.safetyCar.deployed || car.finished) {
|
|
1036
2960
|
car.drsEligible = false;
|
|
1037
2961
|
car.drsActive = false;
|
|
@@ -1059,7 +2983,7 @@ export class F1RaceSimulation {
|
|
|
1059
2983
|
const crossing = recordDrsDetection(car, crossedZone.id, this.time);
|
|
1060
2984
|
const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
|
|
1061
2985
|
car.drsZoneEnabled = Boolean(
|
|
1062
|
-
|
|
2986
|
+
hasReference &&
|
|
1063
2987
|
aheadCrossing &&
|
|
1064
2988
|
aheadCrossing.passage === crossing.passage &&
|
|
1065
2989
|
crossing.time >= aheadCrossing.time &&
|
|
@@ -1080,36 +3004,73 @@ export class F1RaceSimulation {
|
|
|
1080
3004
|
const reportedContacts = new Set();
|
|
1081
3005
|
|
|
1082
3006
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
3007
|
+
const candidates = buildCollisionCandidatePairs(this.cars, { trackLength: this.track.length });
|
|
3008
|
+
for (const [first, second] of candidates) {
|
|
3009
|
+
const collision = detectVehicleCollision(first, second);
|
|
3010
|
+
if (!collision) continue;
|
|
3011
|
+
const firstPitControlled = isPitPositionControlledCar(first);
|
|
3012
|
+
const secondPitControlled = isPitPositionControlledCar(second);
|
|
3013
|
+
if (firstPitControlled && secondPitControlled) continue;
|
|
3014
|
+
const stewardCollision = createCollisionStewardContext(first, second, {
|
|
3015
|
+
...collision,
|
|
3016
|
+
trackLength: this.track.length,
|
|
3017
|
+
});
|
|
3018
|
+
const oneCarFixed = firstPitControlled || secondPitControlled;
|
|
3019
|
+
|
|
3020
|
+
const correction = Math.min(
|
|
3021
|
+
(oneCarFixed ? collision.depth : collision.depth / 2) + 0.65,
|
|
3022
|
+
MAX_COLLISION_CORRECTION,
|
|
3023
|
+
);
|
|
3024
|
+
const firstCorrectionX = firstPitControlled ? 0 : -collision.axis.x * correction;
|
|
3025
|
+
const firstCorrectionY = firstPitControlled ? 0 : -collision.axis.y * correction;
|
|
3026
|
+
const secondCorrectionX = secondPitControlled ? 0 : collision.axis.x * correction;
|
|
3027
|
+
const secondCorrectionY = secondPitControlled ? 0 : collision.axis.y * correction;
|
|
3028
|
+
if (!firstPitControlled) {
|
|
3029
|
+
first.x += firstCorrectionX;
|
|
3030
|
+
first.y += firstCorrectionY;
|
|
3031
|
+
}
|
|
3032
|
+
if (!secondPitControlled) {
|
|
3033
|
+
second.x += secondCorrectionX;
|
|
3034
|
+
second.y += secondCorrectionY;
|
|
3035
|
+
}
|
|
1098
3036
|
|
|
3037
|
+
if (oneCarFixed) {
|
|
3038
|
+
if (!firstPitControlled) first.speed = clamp(first.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
|
|
3039
|
+
if (!secondPitControlled) second.speed = clamp(second.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
|
|
3040
|
+
} else {
|
|
1099
3041
|
this.applyContactVelocityResponse(first, second, collision.axis);
|
|
3042
|
+
}
|
|
1100
3043
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
3044
|
+
const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
|
|
3045
|
+
const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
|
|
3046
|
+
const firstHeadingCorrection = firstPitControlled ? 0 : -collision.axis.y * yawNudge;
|
|
3047
|
+
const secondHeadingCorrection = secondPitControlled ? 0 : collision.axis.y * yawNudge;
|
|
3048
|
+
if (!firstPitControlled) {
|
|
3049
|
+
first.heading = normalizeAngle(first.heading + firstHeadingCorrection);
|
|
3050
|
+
shiftPreviousRenderPose(first, firstCorrectionX, firstCorrectionY, firstHeadingCorrection);
|
|
3051
|
+
}
|
|
3052
|
+
if (!secondPitControlled) {
|
|
3053
|
+
second.heading = normalizeAngle(second.heading + secondHeadingCorrection);
|
|
3054
|
+
shiftPreviousRenderPose(second, secondCorrectionX, secondCorrectionY, secondHeadingCorrection);
|
|
3055
|
+
}
|
|
3056
|
+
first.contactCooldown = 1;
|
|
3057
|
+
second.contactCooldown = 1;
|
|
3058
|
+
|
|
3059
|
+
const contactKey = `${first.id}:${second.id}`;
|
|
3060
|
+
if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
|
|
3061
|
+
reportedContacts.add(contactKey);
|
|
3062
|
+
this.events.unshift({
|
|
3063
|
+
type: 'contact',
|
|
3064
|
+
at: this.time,
|
|
3065
|
+
carId: first.id,
|
|
3066
|
+
otherCarId: second.id,
|
|
3067
|
+
firstShapeId: collision.firstShapeId,
|
|
3068
|
+
secondShapeId: collision.secondShapeId,
|
|
3069
|
+
contactType: collision.contactType,
|
|
3070
|
+
depth: collision.depth,
|
|
3071
|
+
timeOfImpact: collision.timeOfImpact,
|
|
3072
|
+
});
|
|
3073
|
+
this.reviewCollision(first, second, stewardCollision);
|
|
1113
3074
|
}
|
|
1114
3075
|
}
|
|
1115
3076
|
}
|