@inventure71/paddockjs 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/INSTALL_AND_UPDATE.md +165 -0
- package/README.md +217 -20
- package/docs/architecture.md +84 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +412 -20
- package/docs/data_model.md +2 -1
- package/docs/index.md +1 -0
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +74 -20
- package/docs/training.md +206 -0
- package/examples/train-basic-policy.mjs +276 -0
- package/examples/trainingData.mjs +133 -0
- package/package.json +18 -8
- package/src/api/PaddockSimulatorController.js +39 -4
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +1251 -146
- package/src/app/domBindings.js +10 -2
- package/src/config/defaultOptions.js +11 -3
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +68 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/evaluation.js +184 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +521 -0
- package/src/environment/index.js +15 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +284 -0
- package/src/environment/options.js +157 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +175 -0
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +367 -0
- package/src/environment/specs.js +163 -0
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +405 -6
- package/src/index.js +27 -2
- package/src/rendering/proceduralTrackAsset.js +343 -24
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2140 -171
- package/src/simulation/rules/collisionSteward.js +69 -0
- package/src/simulation/rules/penaltyLedger.js +153 -0
- package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
- package/src/simulation/rules/tireRequirementSteward.js +29 -0
- package/src/simulation/rules/trackLimitsSteward.js +85 -0
- package/src/simulation/rulesConfig.js +352 -0
- package/src/simulation/trackModel.js +1154 -76
- package/src/simulation/units.js +6 -4
- package/src/simulation/vehicleGeometry.js +247 -0
- package/src/simulation/vehiclePhysics.js +82 -50
- package/src/simulation/wheelSurface.js +338 -0
- package/src/styles.css +369 -67
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +45 -81
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -8,30 +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;
|
|
35
|
+
export const FIXED_STEP = 1 / 60;
|
|
36
|
+
const MIN_TOTAL_LAPS = 1;
|
|
16
37
|
const MAX_COLLISION_CORRECTION = 4.5;
|
|
17
|
-
const GRID_SLOT_SPACING =
|
|
18
|
-
const
|
|
19
|
-
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);
|
|
20
43
|
const TIMING_HISTORY_WINDOW_SECONDS = 18;
|
|
21
44
|
const TIMING_HISTORY_MAX_SAMPLES = 720;
|
|
45
|
+
const TIMING_LINE_TARGET_SPACING_METERS = 175;
|
|
46
|
+
const TIMING_LINE_HISTORY_LAPS = 3;
|
|
22
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
|
+
}
|
|
23
79
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
safetyCarGap: 128,
|
|
29
|
-
collisionRestitution: 0.18,
|
|
30
|
-
standingStart: true,
|
|
31
|
-
startLightCount: 5,
|
|
32
|
-
startLightInterval: 0.72,
|
|
33
|
-
startLightsOutHold: 0.78,
|
|
34
|
-
};
|
|
80
|
+
function isPitPositionControlledCar(car) {
|
|
81
|
+
const status = car?.pitStop?.status;
|
|
82
|
+
return status === 'entering' || status === 'queued' || status === 'servicing' || status === 'exiting';
|
|
83
|
+
}
|
|
35
84
|
|
|
36
85
|
function wrapProgress(value, length) {
|
|
37
86
|
return wrapDistance(value, length);
|
|
@@ -95,6 +144,15 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
|
|
|
95
144
|
};
|
|
96
145
|
}
|
|
97
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
|
+
|
|
98
156
|
function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps = Infinity) {
|
|
99
157
|
const position = getLapTelemetryPosition(track, raceDistance, totalLaps);
|
|
100
158
|
return {
|
|
@@ -105,6 +163,8 @@ function createLapTelemetry(track, currentTime = 0, raceDistance = 0, totalLaps
|
|
|
105
163
|
currentLapTime: 0,
|
|
106
164
|
currentSectorElapsed: 0,
|
|
107
165
|
currentSectors: createEmptySectorTimes(),
|
|
166
|
+
sectorProgress: createSectorProgress(position),
|
|
167
|
+
liveSectors: createEmptySectorTimes(),
|
|
108
168
|
lastLapTime: null,
|
|
109
169
|
bestLapTime: null,
|
|
110
170
|
lastSectors: createEmptySectorTimes(),
|
|
@@ -133,6 +193,8 @@ function serializeLapTelemetry(telemetry) {
|
|
|
133
193
|
currentSectorElapsed: finiteOrNull(telemetry.currentSectorElapsed),
|
|
134
194
|
currentSectorProgress: finiteOrNull(telemetry.currentSectorProgress),
|
|
135
195
|
currentSectors: serializeSectorTimes(telemetry.currentSectors),
|
|
196
|
+
sectorProgress: serializeSectorTimes(telemetry.sectorProgress),
|
|
197
|
+
liveSectors: serializeSectorTimes(telemetry.liveSectors),
|
|
136
198
|
lastLapTime: finiteOrNull(telemetry.lastLapTime),
|
|
137
199
|
bestLapTime: finiteOrNull(telemetry.bestLapTime),
|
|
138
200
|
lastSectors: serializeSectorTimes(telemetry.lastSectors),
|
|
@@ -157,14 +219,36 @@ function updateBestSector(telemetry, sectorIndex, sectorTime) {
|
|
|
157
219
|
}
|
|
158
220
|
}
|
|
159
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
|
+
|
|
160
241
|
function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
|
|
161
242
|
const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
|
|
162
243
|
telemetry.currentLap = position.currentLap;
|
|
163
244
|
telemetry.currentSector = position.currentSector;
|
|
164
245
|
telemetry.currentSectorProgress = position.currentSectorProgress;
|
|
246
|
+
clearFutureSectorTelemetry(telemetry);
|
|
247
|
+
telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
|
|
165
248
|
telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
|
|
166
249
|
telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
|
|
167
250
|
telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
|
|
251
|
+
syncLiveSectorTelemetry(telemetry, track);
|
|
168
252
|
}
|
|
169
253
|
|
|
170
254
|
function classifySectorPerformance(value, personalBest, overallBest) {
|
|
@@ -255,14 +339,13 @@ function updateLapTelemetry(car, previousRaceDistance, currentTime, track, total
|
|
|
255
339
|
}
|
|
256
340
|
|
|
257
341
|
function createCar(driver, index, random, track, { standingStart = false } = {}) {
|
|
258
|
-
const gridDistance
|
|
342
|
+
const { gridDistance, gridOffset } = getStartGridSlot(index, { standingStart });
|
|
259
343
|
const start = pointAt(track, gridDistance);
|
|
260
|
-
const
|
|
261
|
-
const position = offsetTrackPoint(start, offset);
|
|
344
|
+
const position = offsetTrackPoint(start, gridOffset);
|
|
262
345
|
const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
|
|
263
346
|
const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
|
|
264
347
|
const personality = buildDriverPersonality(driver, index, racecraft, random);
|
|
265
|
-
const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
|
|
348
|
+
const launchSpeed = kphToSimSpeed(Math.max(70, 84 - index * 0.55 + pace * 4));
|
|
266
349
|
const vehicle = driver.vehicle ?? {};
|
|
267
350
|
|
|
268
351
|
return {
|
|
@@ -305,8 +388,8 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
305
388
|
aggression: personality.baseAggression,
|
|
306
389
|
gridLocked: standingStart,
|
|
307
390
|
gridDistance,
|
|
308
|
-
gridOffset
|
|
309
|
-
desiredOffset:
|
|
391
|
+
gridOffset,
|
|
392
|
+
desiredOffset: gridOffset,
|
|
310
393
|
progress: start.distance,
|
|
311
394
|
raceDistance: gridDistance,
|
|
312
395
|
lap: 1,
|
|
@@ -315,6 +398,9 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
315
398
|
gapAheadSeconds: Infinity,
|
|
316
399
|
leaderGapSeconds: 0,
|
|
317
400
|
intervalAheadSeconds: Infinity,
|
|
401
|
+
gapAheadLaps: 0,
|
|
402
|
+
intervalAheadLaps: 0,
|
|
403
|
+
leaderGapLaps: 0,
|
|
318
404
|
drsEligible: false,
|
|
319
405
|
drsActive: false,
|
|
320
406
|
drsZoneId: null,
|
|
@@ -326,85 +412,70 @@ function createCar(driver, index, random, track, { standingStart = false } = {})
|
|
|
326
412
|
trackState: start,
|
|
327
413
|
contactCooldown: 0,
|
|
328
414
|
tireEnergy: 100,
|
|
415
|
+
usedTireCompounds: [driver.tire ?? ['M', 'H', 'S'][index % 3]],
|
|
416
|
+
automaticPitIntentEnabled: true,
|
|
329
417
|
};
|
|
330
418
|
}
|
|
331
419
|
|
|
332
|
-
function
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
min = Math.min(min, projection);
|
|
338
|
-
max = Math.max(max, projection);
|
|
339
|
-
});
|
|
340
|
-
return { min, max };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function overlapOnAxis(a, b, axis) {
|
|
344
|
-
const first = projectOntoAxis(a, axis);
|
|
345
|
-
const second = projectOntoAxis(b, axis);
|
|
346
|
-
return Math.min(first.max, second.max) - Math.max(first.min, second.min);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function detectObbCollision(a, b) {
|
|
350
|
-
const aCorners = getCarCorners(a);
|
|
351
|
-
const bCorners = getCarCorners(b);
|
|
352
|
-
const axes = [
|
|
353
|
-
normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
|
|
354
|
-
normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
|
|
355
|
-
normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
|
|
356
|
-
normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
|
|
357
|
-
];
|
|
358
|
-
|
|
359
|
-
let minimumOverlap = Infinity;
|
|
360
|
-
let minimumAxis = null;
|
|
361
|
-
|
|
362
|
-
for (const axis of axes) {
|
|
363
|
-
const overlap = overlapOnAxis(aCorners, bCorners, axis);
|
|
364
|
-
if (overlap <= 0) return null;
|
|
365
|
-
if (overlap < minimumOverlap) {
|
|
366
|
-
minimumOverlap = overlap;
|
|
367
|
-
minimumAxis = axis;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const direction = { x: b.x - a.x, y: b.y - a.y };
|
|
372
|
-
if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
|
|
373
|
-
minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
|
|
374
|
-
}
|
|
420
|
+
function normalizeTotalLaps(value) {
|
|
421
|
+
const numeric = Number(value);
|
|
422
|
+
if (!Number.isFinite(numeric)) return MIN_TOTAL_LAPS;
|
|
423
|
+
return Math.max(MIN_TOTAL_LAPS, Math.floor(numeric));
|
|
424
|
+
}
|
|
375
425
|
|
|
376
|
-
|
|
426
|
+
function forwardVector(car) {
|
|
427
|
+
return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
|
|
377
428
|
}
|
|
378
429
|
|
|
379
|
-
function
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
|
|
384
|
-
const direction = { x: b.x - a.x, y: b.y - a.y };
|
|
385
|
-
const physicalLongitudinalGap = dot(direction, axis);
|
|
386
|
-
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
|
+
}
|
|
387
434
|
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
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
|
+
}
|
|
391
456
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
};
|
|
396
470
|
|
|
397
471
|
return {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
472
|
+
...collision,
|
|
473
|
+
impactSpeed: Math.max(0, dot(relativeVelocity, directionBehindToAhead)),
|
|
474
|
+
aheadDriverId: ahead.id,
|
|
475
|
+
atFaultDriverId: behind.id,
|
|
401
476
|
};
|
|
402
477
|
}
|
|
403
478
|
|
|
404
|
-
function forwardVector(car) {
|
|
405
|
-
return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
479
|
function dot(a, b) {
|
|
409
480
|
return a.x * b.x + a.y * b.y;
|
|
410
481
|
}
|
|
@@ -469,9 +540,12 @@ function interpolateTimeAtDistance(history, targetDistance) {
|
|
|
469
540
|
return null;
|
|
470
541
|
}
|
|
471
542
|
|
|
472
|
-
function estimateGapAheadSeconds(ahead, car, currentTime) {
|
|
543
|
+
function estimateGapAheadSeconds(ahead, car, currentTime, track) {
|
|
473
544
|
if (!ahead) return Infinity;
|
|
474
545
|
|
|
546
|
+
const timingLineGap = estimateTimingLineGapSeconds(ahead, car, currentTime, track);
|
|
547
|
+
if (Number.isFinite(timingLineGap)) return timingLineGap;
|
|
548
|
+
|
|
475
549
|
const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
|
|
476
550
|
if (Number.isFinite(crossingTime)) {
|
|
477
551
|
return Math.max(0, currentTime - crossingTime);
|
|
@@ -481,6 +555,106 @@ function estimateGapAheadSeconds(ahead, car, currentTime) {
|
|
|
481
555
|
return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
|
|
482
556
|
}
|
|
483
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
|
+
|
|
484
658
|
function recordDrsDetection(car, zoneId, currentTime) {
|
|
485
659
|
const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
|
|
486
660
|
const next = { passage: previous.passage + 1, time: currentTime };
|
|
@@ -491,7 +665,403 @@ function recordDrsDetection(car, zoneId, currentTime) {
|
|
|
491
665
|
return next;
|
|
492
666
|
}
|
|
493
667
|
|
|
494
|
-
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';
|
|
495
1065
|
return {
|
|
496
1066
|
id: car.id,
|
|
497
1067
|
code: car.code,
|
|
@@ -523,8 +1093,14 @@ function serializeCar(car, rank) {
|
|
|
523
1093
|
},
|
|
524
1094
|
rank,
|
|
525
1095
|
classifiedRank: car.classifiedRank ?? rank,
|
|
1096
|
+
finishRank: car.finishRank ?? null,
|
|
1097
|
+
status: raceStatus,
|
|
1098
|
+
raceStatus,
|
|
1099
|
+
wavedFlag: Boolean(car.finished),
|
|
526
1100
|
finished: Boolean(car.finished),
|
|
527
|
-
finishTime
|
|
1101
|
+
finishTime,
|
|
1102
|
+
penaltySeconds,
|
|
1103
|
+
adjustedFinishTime: finishTime == null ? null : finishTime + penaltySeconds,
|
|
528
1104
|
previousX: car.previousX ?? car.x,
|
|
529
1105
|
previousY: car.previousY ?? car.y,
|
|
530
1106
|
x: car.x,
|
|
@@ -549,6 +1125,9 @@ function serializeCar(car, rank) {
|
|
|
549
1125
|
gapAheadSeconds: car.gapAheadSeconds,
|
|
550
1126
|
intervalAheadSeconds: car.intervalAheadSeconds,
|
|
551
1127
|
leaderGapSeconds: car.leaderGapSeconds,
|
|
1128
|
+
gapAheadLaps: car.gapAheadLaps ?? 0,
|
|
1129
|
+
intervalAheadLaps: car.intervalAheadLaps ?? car.gapAheadLaps ?? 0,
|
|
1130
|
+
leaderGapLaps: car.leaderGapLaps ?? 0,
|
|
552
1131
|
drsEligible: car.drsEligible,
|
|
553
1132
|
drsActive: car.drsActive,
|
|
554
1133
|
drsZoneId: car.drsZoneId,
|
|
@@ -557,27 +1136,161 @@ function serializeCar(car, rank) {
|
|
|
557
1136
|
signedOffset: car.trackState?.signedOffset ?? 0,
|
|
558
1137
|
crossTrackError: car.trackState?.crossTrackError ?? 0,
|
|
559
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,
|
|
560
1144
|
contactCooldown: car.contactCooldown,
|
|
561
1145
|
tireEnergy: car.tireEnergy,
|
|
1146
|
+
usedTireCompounds: [...(car.usedTireCompounds ?? [])],
|
|
1147
|
+
pitIntent: normalizePitIntent(car.pitStop?.intent) ?? PIT_INTENT_NONE,
|
|
1148
|
+
pitStop: serializePitStop(car.pitStop),
|
|
562
1149
|
positionSource: 'integrated-vehicle',
|
|
563
1150
|
};
|
|
564
1151
|
}
|
|
565
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
|
+
|
|
566
1265
|
export class F1RaceSimulation {
|
|
567
1266
|
constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
|
|
568
1267
|
this.seed = seed;
|
|
569
1268
|
this.random = createMulberry32(seed);
|
|
570
1269
|
const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
|
|
571
|
-
|
|
1270
|
+
const builtTrack = buildTrackModel(trackDefinition);
|
|
1271
|
+
this.track = {
|
|
1272
|
+
...builtTrack,
|
|
1273
|
+
pitLane: clonePitLaneModel(builtTrack.pitLane),
|
|
1274
|
+
};
|
|
1275
|
+
this.track.timingLines = createTimingLines(this.track);
|
|
572
1276
|
this.trackSeed = this.track.seed ?? trackSeed;
|
|
573
|
-
this.rules =
|
|
1277
|
+
this.rules = normalizeRaceRules(rules);
|
|
574
1278
|
this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
|
|
575
|
-
this.totalLaps = totalLaps;
|
|
1279
|
+
this.totalLaps = normalizeTotalLaps(totalLaps);
|
|
576
1280
|
this.time = 0;
|
|
577
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
|
+
};
|
|
578
1289
|
this.raceControl = {
|
|
579
1290
|
mode: this.rules.standingStart === false ? 'green' : 'pre-start',
|
|
580
1291
|
frozenOrder: null,
|
|
1292
|
+
redFlag: false,
|
|
1293
|
+
pitLaneOpen: true,
|
|
581
1294
|
finished: false,
|
|
582
1295
|
finishedAt: null,
|
|
583
1296
|
winnerId: null,
|
|
@@ -606,15 +1319,118 @@ export class F1RaceSimulation {
|
|
|
606
1319
|
this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
|
|
607
1320
|
standingStart: this.raceControl.mode === 'pre-start',
|
|
608
1321
|
}));
|
|
1322
|
+
this.assignPitLaneTeams();
|
|
1323
|
+
this.initializePitStops();
|
|
609
1324
|
this.recalculateRaceState({ updateDrs: false });
|
|
610
1325
|
this.cars.forEach((car) => resetTimingHistory(car, this.time));
|
|
1326
|
+
this.cars.forEach((car) => resetTimingLineCrossings(car, this.time));
|
|
611
1327
|
this.cars.forEach((car) => resetLapTelemetry(car, this.time, this.track, this.totalLaps));
|
|
612
1328
|
}
|
|
613
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
|
+
|
|
614
1429
|
setSafetyCar(deployed) {
|
|
615
1430
|
const next = Boolean(deployed);
|
|
616
1431
|
if (this.raceControl.finished) return;
|
|
617
1432
|
if (next && this.raceControl.mode === 'pre-start') return;
|
|
1433
|
+
if (this.raceControl.redFlag) return;
|
|
618
1434
|
if (next === this.safetyCar.deployed) return;
|
|
619
1435
|
const ordered = this.orderedCars();
|
|
620
1436
|
this.safetyCar.deployed = next;
|
|
@@ -637,9 +1453,53 @@ export class F1RaceSimulation {
|
|
|
637
1453
|
this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
|
|
638
1454
|
}
|
|
639
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
|
+
|
|
640
1499
|
setCarState(id, partial) {
|
|
641
1500
|
const car = this.cars.find((item) => item.id === id);
|
|
642
1501
|
if (!car) return;
|
|
1502
|
+
const hasExplicitRaceDistance = partial.raceDistance != null;
|
|
643
1503
|
Object.assign(car, partial);
|
|
644
1504
|
if (
|
|
645
1505
|
partial.x != null ||
|
|
@@ -654,9 +1514,21 @@ export class F1RaceSimulation {
|
|
|
654
1514
|
}
|
|
655
1515
|
car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
|
|
656
1516
|
car.heading = normalizeAngle(car.heading);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
+
}
|
|
660
1532
|
car.progress = car.trackState.distance;
|
|
661
1533
|
car.lap = this.computeLap(car.raceDistance);
|
|
662
1534
|
car.drsZoneId = null;
|
|
@@ -665,6 +1537,7 @@ export class F1RaceSimulation {
|
|
|
665
1537
|
car.drsEligible = false;
|
|
666
1538
|
car.drsDetection = {};
|
|
667
1539
|
resetTimingHistory(car, this.time);
|
|
1540
|
+
resetTimingLineCrossings(car, this.time);
|
|
668
1541
|
resetLapTelemetry(car, this.time, this.track, this.totalLaps);
|
|
669
1542
|
this.recalculateRaceState({ updateDrs: false });
|
|
670
1543
|
this.evaluateRaceFinish();
|
|
@@ -681,8 +1554,712 @@ export class F1RaceSimulation {
|
|
|
681
1554
|
if (car) car.manualControls = null;
|
|
682
1555
|
}
|
|
683
1556
|
|
|
684
|
-
|
|
685
|
-
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);
|
|
686
2263
|
if (!Number.isFinite(delta) || delta <= 0) return;
|
|
687
2264
|
|
|
688
2265
|
this.time += delta;
|
|
@@ -696,6 +2273,24 @@ export class F1RaceSimulation {
|
|
|
696
2273
|
return;
|
|
697
2274
|
}
|
|
698
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
|
+
|
|
699
2294
|
this.updateSafetyCar(delta);
|
|
700
2295
|
|
|
701
2296
|
const orderedCars = this.orderedCars();
|
|
@@ -705,6 +2300,7 @@ export class F1RaceSimulation {
|
|
|
705
2300
|
car.previousY = car.y;
|
|
706
2301
|
car.previousHeading = car.heading;
|
|
707
2302
|
car.previousProgress = car.progress;
|
|
2303
|
+
if (this.advancePitStopCar(car, delta)) return;
|
|
708
2304
|
const controls = decideDriverControls({
|
|
709
2305
|
car,
|
|
710
2306
|
orderIndex: index,
|
|
@@ -717,11 +2313,169 @@ export class F1RaceSimulation {
|
|
|
717
2313
|
|
|
718
2314
|
this.resolveCollisions();
|
|
719
2315
|
this.recalculateRaceState();
|
|
720
|
-
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
|
+
});
|
|
721
2473
|
}
|
|
722
2474
|
|
|
723
2475
|
snapshot() {
|
|
724
2476
|
const ordered = this.orderedCars();
|
|
2477
|
+
const pitLaneStatus = pitLaneStatusSnapshot(this.raceControl, this.track.pitLane, this.rules.modules?.pitStops);
|
|
2478
|
+
const penaltyStats = this.getPenaltyStatsByDriver();
|
|
725
2479
|
return {
|
|
726
2480
|
time: this.time,
|
|
727
2481
|
world: WORLD,
|
|
@@ -729,6 +2483,9 @@ export class F1RaceSimulation {
|
|
|
729
2483
|
totalLaps: this.totalLaps,
|
|
730
2484
|
raceControl: {
|
|
731
2485
|
mode: this.raceControl.mode,
|
|
2486
|
+
redFlag: Boolean(this.raceControl.redFlag),
|
|
2487
|
+
pitLaneOpen: pitLaneStatus.open,
|
|
2488
|
+
pitLaneStatus,
|
|
732
2489
|
finished: this.raceControl.finished,
|
|
733
2490
|
finishedAt: this.raceControl.finishedAt,
|
|
734
2491
|
winner: this.getRaceWinnerSnapshot(),
|
|
@@ -739,23 +2496,99 @@ export class F1RaceSimulation {
|
|
|
739
2496
|
(this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
|
|
740
2497
|
},
|
|
741
2498
|
},
|
|
2499
|
+
pitLaneStatus,
|
|
742
2500
|
safetyCar: { ...this.safetyCar },
|
|
743
2501
|
rules: this.rules,
|
|
744
2502
|
events: [...this.events],
|
|
745
|
-
|
|
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),
|
|
746
2530
|
};
|
|
747
2531
|
}
|
|
748
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
|
+
|
|
749
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
|
+
|
|
750
2587
|
if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
|
|
751
|
-
const byId = new Map(this.cars.map((car) => [car.id, car]));
|
|
752
2588
|
return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
|
|
753
2589
|
}
|
|
754
2590
|
|
|
755
|
-
return
|
|
756
|
-
const delta = b.raceDistance - a.raceDistance;
|
|
757
|
-
return delta === 0 ? a.index - b.index : delta;
|
|
758
|
-
});
|
|
2591
|
+
return sortLive(this.cars);
|
|
759
2592
|
}
|
|
760
2593
|
|
|
761
2594
|
driverRaceContext(orderedCars = this.orderedCars()) {
|
|
@@ -818,9 +2651,10 @@ export class F1RaceSimulation {
|
|
|
818
2651
|
const wasGridLocked = car.gridLocked;
|
|
819
2652
|
car.gridLocked = false;
|
|
820
2653
|
const state = nearestTrackState(this.track, car, car.gridDistance);
|
|
821
|
-
car.trackState = state;
|
|
822
2654
|
car.progress = state.distance;
|
|
2655
|
+
applyWheelSurfaceState(car, this.track);
|
|
823
2656
|
if (wasGridLocked) car.raceDistance = car.gridDistance;
|
|
2657
|
+
if (wasGridLocked) car.desiredOffset = 0;
|
|
824
2658
|
car.previousX = car.x;
|
|
825
2659
|
car.previousY = car.y;
|
|
826
2660
|
car.previousHeading = car.heading;
|
|
@@ -846,7 +2680,7 @@ export class F1RaceSimulation {
|
|
|
846
2680
|
car.turnRadius = Infinity;
|
|
847
2681
|
car.progress = gridPoint.distance;
|
|
848
2682
|
car.raceDistance = car.gridDistance;
|
|
849
|
-
car
|
|
2683
|
+
applyWheelSurfaceState(car, this.track);
|
|
850
2684
|
});
|
|
851
2685
|
}
|
|
852
2686
|
|
|
@@ -854,7 +2688,9 @@ export class F1RaceSimulation {
|
|
|
854
2688
|
if (!this.safetyCar.deployed) return;
|
|
855
2689
|
const leader = this.orderedCars()[0];
|
|
856
2690
|
const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
|
|
857
|
-
const progress =
|
|
2691
|
+
const progress = this.safetyCar.progress < targetProgress
|
|
2692
|
+
? Math.min(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress)
|
|
2693
|
+
: targetProgress;
|
|
858
2694
|
this.moveSafetyCarTo(progress);
|
|
859
2695
|
}
|
|
860
2696
|
|
|
@@ -871,10 +2707,14 @@ export class F1RaceSimulation {
|
|
|
871
2707
|
|
|
872
2708
|
applyRunoffResponse(car) {
|
|
873
2709
|
const state = nearestTrackState(this.track, car, car.progress);
|
|
2710
|
+
if (state.inPitLane) {
|
|
2711
|
+
applyWheelSurfaceState(car, this.track, { centerState: state });
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
874
2714
|
const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
|
|
875
2715
|
const overshoot = Math.abs(state.signedOffset) - signedLimit;
|
|
876
2716
|
if (overshoot <= 0) {
|
|
877
|
-
car.
|
|
2717
|
+
applyWheelSurfaceState(car, this.track, { centerState: state });
|
|
878
2718
|
return;
|
|
879
2719
|
}
|
|
880
2720
|
|
|
@@ -883,7 +2723,7 @@ export class F1RaceSimulation {
|
|
|
883
2723
|
car.y -= state.normalY * side * overshoot;
|
|
884
2724
|
car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
|
|
885
2725
|
car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
|
|
886
|
-
car
|
|
2726
|
+
applyWheelSurfaceState(car, this.track);
|
|
887
2727
|
}
|
|
888
2728
|
|
|
889
2729
|
recalculateRaceState({ updateDrs = true } = {}) {
|
|
@@ -891,7 +2731,7 @@ export class F1RaceSimulation {
|
|
|
891
2731
|
const previousRaceDistance = car.raceDistance;
|
|
892
2732
|
if (car.gridLocked) {
|
|
893
2733
|
const gridPoint = pointAt(this.track, car.gridDistance);
|
|
894
|
-
car
|
|
2734
|
+
applyWheelSurfaceState(car, this.track);
|
|
895
2735
|
car.progress = gridPoint.distance;
|
|
896
2736
|
car.raceDistance = car.gridDistance;
|
|
897
2737
|
car.lap = 1;
|
|
@@ -899,7 +2739,7 @@ export class F1RaceSimulation {
|
|
|
899
2739
|
return;
|
|
900
2740
|
}
|
|
901
2741
|
|
|
902
|
-
car
|
|
2742
|
+
applyWheelSurfaceState(car, this.track);
|
|
903
2743
|
const previousProgress = car.progress ?? car.trackState.distance;
|
|
904
2744
|
const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
|
|
905
2745
|
car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
|
|
@@ -909,20 +2749,41 @@ export class F1RaceSimulation {
|
|
|
909
2749
|
});
|
|
910
2750
|
|
|
911
2751
|
updateSectorPerformance(this.cars);
|
|
912
|
-
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
|
+
});
|
|
913
2757
|
|
|
914
2758
|
const ordered = this.orderedCars();
|
|
2759
|
+
const leader = ordered[0];
|
|
915
2760
|
ordered.forEach((car, index) => {
|
|
916
2761
|
const ahead = ordered[index - 1];
|
|
2762
|
+
const drsReference = this.getDrsReferenceCar(car);
|
|
917
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);
|
|
918
2767
|
car.rank = index + 1;
|
|
919
2768
|
car.gapAhead = gap;
|
|
920
|
-
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;
|
|
921
2775
|
car.intervalAheadSeconds = car.gapAheadSeconds;
|
|
922
|
-
car.leaderGapSeconds =
|
|
923
|
-
|
|
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;
|
|
924
2780
|
car.aggression = this.computeAggression(car, index);
|
|
925
|
-
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));
|
|
926
2787
|
});
|
|
927
2788
|
this.evaluateRaceFinish();
|
|
928
2789
|
}
|
|
@@ -938,7 +2799,8 @@ export class F1RaceSimulation {
|
|
|
938
2799
|
newlyFinished.forEach((car) => {
|
|
939
2800
|
car.finished = true;
|
|
940
2801
|
car.finishTime = this.time;
|
|
941
|
-
car.
|
|
2802
|
+
car.finishRank = this.raceControl.finishOrder.length + 1;
|
|
2803
|
+
car.classifiedRank = car.finishRank;
|
|
942
2804
|
this.raceControl.finishOrder.push(car.id);
|
|
943
2805
|
if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
|
|
944
2806
|
car.drsActive = false;
|
|
@@ -950,14 +2812,17 @@ export class F1RaceSimulation {
|
|
|
950
2812
|
type: 'car-finish',
|
|
951
2813
|
at: this.time,
|
|
952
2814
|
carId: car.id,
|
|
953
|
-
rank: car.
|
|
2815
|
+
rank: car.finishRank,
|
|
954
2816
|
winnerId: this.raceControl.winnerId,
|
|
955
2817
|
});
|
|
2818
|
+
this.reviewTireRequirement(car);
|
|
956
2819
|
});
|
|
957
2820
|
|
|
958
2821
|
if (!this.cars.every((car) => car.finished)) return;
|
|
959
2822
|
|
|
2823
|
+
this.applyOutstandingServicePenalties();
|
|
960
2824
|
const classification = this.buildClassificationFromFinishOrder();
|
|
2825
|
+
this.raceControl.winnerId = classification[0]?.id ?? this.raceControl.winnerId;
|
|
961
2826
|
this.raceControl.mode = 'safety-car';
|
|
962
2827
|
this.raceControl.finished = true;
|
|
963
2828
|
this.raceControl.finishedAt = this.time;
|
|
@@ -987,32 +2852,88 @@ export class F1RaceSimulation {
|
|
|
987
2852
|
});
|
|
988
2853
|
}
|
|
989
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
|
+
|
|
990
2872
|
buildClassificationFromFinishOrder() {
|
|
991
2873
|
const byId = new Map(this.cars.map((car) => [car.id, car]));
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
.
|
|
995
|
-
|
|
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);
|
|
996
2886
|
}
|
|
997
2887
|
|
|
998
|
-
|
|
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()) {
|
|
999
2907
|
const leaderDistance = ordered[0]?.raceDistance ?? 0;
|
|
1000
|
-
return ordered.map((car, index) =>
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
+
});
|
|
1016
2937
|
}
|
|
1017
2938
|
|
|
1018
2939
|
getRaceWinnerSnapshot() {
|
|
@@ -1020,10 +2941,21 @@ export class F1RaceSimulation {
|
|
|
1020
2941
|
const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
|
|
1021
2942
|
if (!car) return null;
|
|
1022
2943
|
const rank = car.classifiedRank ?? car.rank ?? 1;
|
|
1023
|
-
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;
|
|
1024
2956
|
}
|
|
1025
2957
|
|
|
1026
|
-
updateDrsLatch(car, ahead,
|
|
2958
|
+
updateDrsLatch(car, ahead, hasReference = Boolean(ahead)) {
|
|
1027
2959
|
if (this.safetyCar.deployed || car.finished) {
|
|
1028
2960
|
car.drsEligible = false;
|
|
1029
2961
|
car.drsActive = false;
|
|
@@ -1051,7 +2983,7 @@ export class F1RaceSimulation {
|
|
|
1051
2983
|
const crossing = recordDrsDetection(car, crossedZone.id, this.time);
|
|
1052
2984
|
const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
|
|
1053
2985
|
car.drsZoneEnabled = Boolean(
|
|
1054
|
-
|
|
2986
|
+
hasReference &&
|
|
1055
2987
|
aheadCrossing &&
|
|
1056
2988
|
aheadCrossing.passage === crossing.passage &&
|
|
1057
2989
|
crossing.time >= aheadCrossing.time &&
|
|
@@ -1072,36 +3004,73 @@ export class F1RaceSimulation {
|
|
|
1072
3004
|
const reportedContacts = new Set();
|
|
1073
3005
|
|
|
1074
3006
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
+
}
|
|
1090
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 {
|
|
1091
3041
|
this.applyContactVelocityResponse(first, second, collision.axis);
|
|
3042
|
+
}
|
|
1092
3043
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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);
|
|
1105
3074
|
}
|
|
1106
3075
|
}
|
|
1107
3076
|
}
|