@inventure71/paddockjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +86 -0
  2. package/CHANGELOG.md +5 -0
  3. package/LICENSE +176 -0
  4. package/README.md +165 -0
  5. package/assets/f1-broadcast-panel-surface.png +0 -0
  6. package/assets/f1-car-sprite-game.png +0 -0
  7. package/assets/f1-driver-helmet.png +0 -0
  8. package/assets/f1-safety-car-sprite.png +0 -0
  9. package/assets/f1-texture-asphalt.png +0 -0
  10. package/assets/f1Logo.png +0 -0
  11. package/docs/architecture.md +209 -0
  12. package/docs/concepts.md +178 -0
  13. package/docs/data_contract.md +360 -0
  14. package/docs/data_model.md +167 -0
  15. package/docs/index.md +15 -0
  16. package/docs/learnings.md +47 -0
  17. package/docs/rules.md +181 -0
  18. package/docs/system_specs.md +211 -0
  19. package/package.json +85 -0
  20. package/src/api/PaddockSimulatorController.js +233 -0
  21. package/src/app/F1SimulatorApp.js +1293 -0
  22. package/src/app/domBindings.js +64 -0
  23. package/src/config/defaultAssets.js +29 -0
  24. package/src/config/defaultOptions.js +184 -0
  25. package/src/data/championship.js +225 -0
  26. package/src/data/customFields.js +14 -0
  27. package/src/data/demoDrivers.js +134 -0
  28. package/src/data/driverData.js +71 -0
  29. package/src/data/normalizeDrivers.js +21 -0
  30. package/src/data/vehicleData.js +81 -0
  31. package/src/index.d.ts +399 -0
  32. package/src/index.js +116 -0
  33. package/src/rendering/proceduralTrackAsset.js +517 -0
  34. package/src/rendering/renderSnapshot.js +23 -0
  35. package/src/simulation/driverController.js +253 -0
  36. package/src/simulation/raceSimulation.js +933 -0
  37. package/src/simulation/simMath.js +34 -0
  38. package/src/simulation/trackModel.js +607 -0
  39. package/src/simulation/units.js +30 -0
  40. package/src/simulation/vehiclePhysics.js +104 -0
  41. package/src/standalone.js +15 -0
  42. package/src/styles.css +1818 -0
  43. package/src/ui/componentTemplates.js +229 -0
  44. package/src/ui/shellTemplate.js +43 -0
@@ -0,0 +1,933 @@
1
+ import {
2
+ buildTrackModel,
3
+ createProceduralTrack,
4
+ nearestTrackState,
5
+ offsetTrackPoint,
6
+ pointAt,
7
+ TRACK,
8
+ WORLD,
9
+ } from './trackModel.js';
10
+ import { buildDriverPersonality, decideDriverControls } from './driverController.js';
11
+ import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
12
+ import { simSpeedToKph, simUnitsToMeters } from './units.js';
13
+ import { getCarCorners, integrateVehiclePhysics, VEHICLE_LIMITS } from './vehiclePhysics.js';
14
+
15
+ const DEFAULT_TOTAL_LAPS = 10;
16
+ const MAX_COLLISION_CORRECTION = 4.5;
17
+ const GRID_SLOT_SPACING = 82;
18
+ const GRID_FIRST_SLOT_DISTANCE = -42;
19
+ const GRID_LATERAL_OFFSET = 42;
20
+ const TIMING_HISTORY_WINDOW_SECONDS = 18;
21
+ const TIMING_HISTORY_MAX_SAMPLES = 720;
22
+
23
+ export const DEFAULT_RULES = {
24
+ drsDetectionSeconds: 1,
25
+ safetyCarSpeed: 46,
26
+ safetyCarLeadDistance: 122,
27
+ safetyCarGap: 128,
28
+ collisionRestitution: 0.18,
29
+ standingStart: true,
30
+ startLightCount: 5,
31
+ startLightInterval: 0.72,
32
+ startLightsOutHold: 0.78,
33
+ };
34
+
35
+ function wrapProgress(value, length) {
36
+ return wrapDistance(value, length);
37
+ }
38
+
39
+ function distanceForward(from, to, length) {
40
+ return wrapProgress(to - from, length);
41
+ }
42
+
43
+ function crossesDistance(previous, current, target, length) {
44
+ const travelled = distanceForward(previous, current, length);
45
+ if (travelled <= 0 || travelled > length / 2) return false;
46
+ const targetOffset = distanceForward(previous, target, length);
47
+ return targetOffset > 0 && targetOffset <= travelled + 0.001;
48
+ }
49
+
50
+ function isProgressInZone(track, progress, zone) {
51
+ const wrapped = wrapProgress(progress, track.length);
52
+ const start = wrapProgress(zone.start, track.length);
53
+ const end = wrapProgress(zone.end, track.length);
54
+ if (zone.end - zone.start >= track.length) return true;
55
+ return end >= start
56
+ ? wrapped >= start && wrapped <= end
57
+ : wrapped >= start || wrapped <= end;
58
+ }
59
+
60
+ function createCar(driver, index, random, track, { standingStart = false } = {}) {
61
+ const gridDistance = GRID_FIRST_SLOT_DISTANCE - index * GRID_SLOT_SPACING;
62
+ const start = pointAt(track, gridDistance);
63
+ const offset = index % 2 === 0 ? -GRID_LATERAL_OFFSET : GRID_LATERAL_OFFSET;
64
+ const position = offsetTrackPoint(start, offset);
65
+ const pace = clamp(driver.pace ?? seededRange(random, 0.95, 1.05), 0.88, 1.12);
66
+ const racecraft = clamp(driver.racecraft ?? seededRange(random, 0.65, 0.92), 0.45, 1);
67
+ const personality = buildDriverPersonality(driver, index, racecraft, random);
68
+ const launchSpeed = Math.max(70, 84 - index * 0.55 + pace * 4);
69
+ const vehicle = driver.vehicle ?? {};
70
+
71
+ return {
72
+ id: driver.id ?? `car-${index + 1}`,
73
+ code: driver.code ?? `C${index + 1}`,
74
+ timingCode: driver.timingCode ?? driver.code ?? `C${index + 1}`,
75
+ driverNumber: driver.driverNumber ?? index + 1,
76
+ icon: driver.icon ?? String(index + 1).padStart(2, '0'),
77
+ raceName: driver.raceName ?? driver.code ?? `CAR${index + 1}`,
78
+ name: driver.name ?? `Car ${index + 1}`,
79
+ color: driver.color ?? '#e10600',
80
+ team: driver.team ? { ...driver.team } : null,
81
+ tire: driver.tire ?? ['M', 'H', 'S'][index % 3],
82
+ index,
83
+ x: position.x,
84
+ y: position.y,
85
+ previousX: position.x,
86
+ previousY: position.y,
87
+ heading: start.heading,
88
+ previousHeading: start.heading,
89
+ steeringAngle: 0,
90
+ yawRate: 0,
91
+ turnRadius: Infinity,
92
+ speed: standingStart ? 0 : launchSpeed,
93
+ throttle: 0,
94
+ brake: 0,
95
+ vehicleId: vehicle.id ?? null,
96
+ vehicleName: vehicle.name ?? null,
97
+ vehicleRatings: vehicle.ratings ? { ...vehicle.ratings } : null,
98
+ mass: vehicle.mass ?? 798 + seededRange(random, -5, 5),
99
+ powerNewtons: vehicle.powerNewtons ?? 43000 * pace,
100
+ brakeNewtons: vehicle.brakeNewtons ?? 59000,
101
+ dragCoefficient: vehicle.dragCoefficient ?? 0.33 + seededRange(random, -0.026, 0.026),
102
+ downforceCoefficient: vehicle.downforceCoefficient ?? 6.1 + seededRange(random, -0.18, 0.18),
103
+ tireGrip: vehicle.tireGrip ?? 2.22 + racecraft * 0.28 + seededRange(random, -0.03, 0.03),
104
+ tireCare: vehicle.tireCare ?? 1,
105
+ pace,
106
+ racecraft,
107
+ personality,
108
+ aggression: personality.baseAggression,
109
+ gridLocked: standingStart,
110
+ gridDistance,
111
+ gridOffset: offset,
112
+ desiredOffset: offset,
113
+ progress: start.distance,
114
+ raceDistance: gridDistance,
115
+ lap: 1,
116
+ rank: index + 1,
117
+ gapAhead: Infinity,
118
+ gapAheadSeconds: Infinity,
119
+ leaderGapSeconds: 0,
120
+ intervalAheadSeconds: Infinity,
121
+ drsEligible: false,
122
+ drsActive: false,
123
+ drsZoneId: null,
124
+ drsZoneEnabled: false,
125
+ timingHistory: [],
126
+ drsDetection: {},
127
+ canAttack: true,
128
+ trackState: start,
129
+ contactCooldown: 0,
130
+ tireEnergy: 100,
131
+ };
132
+ }
133
+
134
+ function projectOntoAxis(points, axis) {
135
+ let min = Infinity;
136
+ let max = -Infinity;
137
+ points.forEach((point) => {
138
+ const projection = point.x * axis.x + point.y * axis.y;
139
+ min = Math.min(min, projection);
140
+ max = Math.max(max, projection);
141
+ });
142
+ return { min, max };
143
+ }
144
+
145
+ function overlapOnAxis(a, b, axis) {
146
+ const first = projectOntoAxis(a, axis);
147
+ const second = projectOntoAxis(b, axis);
148
+ return Math.min(first.max, second.max) - Math.max(first.min, second.min);
149
+ }
150
+
151
+ function detectObbCollision(a, b) {
152
+ const aCorners = getCarCorners(a);
153
+ const bCorners = getCarCorners(b);
154
+ const axes = [
155
+ normalizeVector({ x: aCorners[1].x - aCorners[0].x, y: aCorners[1].y - aCorners[0].y }),
156
+ normalizeVector({ x: aCorners[3].x - aCorners[0].x, y: aCorners[3].y - aCorners[0].y }),
157
+ normalizeVector({ x: bCorners[1].x - bCorners[0].x, y: bCorners[1].y - bCorners[0].y }),
158
+ normalizeVector({ x: bCorners[3].x - bCorners[0].x, y: bCorners[3].y - bCorners[0].y }),
159
+ ];
160
+
161
+ let minimumOverlap = Infinity;
162
+ let minimumAxis = null;
163
+
164
+ for (const axis of axes) {
165
+ const overlap = overlapOnAxis(aCorners, bCorners, axis);
166
+ if (overlap <= 0) return null;
167
+ if (overlap < minimumOverlap) {
168
+ minimumOverlap = overlap;
169
+ minimumAxis = axis;
170
+ }
171
+ }
172
+
173
+ const direction = { x: b.x - a.x, y: b.y - a.y };
174
+ if (direction.x * minimumAxis.x + direction.y * minimumAxis.y < 0) {
175
+ minimumAxis = { x: -minimumAxis.x, y: -minimumAxis.y };
176
+ }
177
+
178
+ return { axis: minimumAxis, depth: minimumOverlap };
179
+ }
180
+
181
+ function detectLongitudinalCollision(a, b) {
182
+ const longitudinalGap = b.raceDistance - a.raceDistance;
183
+ const headingDelta = Math.abs(normalizeAngle(a.heading - b.heading));
184
+ const blendedHeading = a.heading + normalizeAngle(b.heading - a.heading) * 0.5;
185
+ let axis = normalizeVector({ x: Math.cos(blendedHeading), y: Math.sin(blendedHeading) });
186
+ const direction = { x: b.x - a.x, y: b.y - a.y };
187
+ const physicalLongitudinalGap = dot(direction, axis);
188
+ if (physicalLongitudinalGap < 0) axis = { x: -axis.x, y: -axis.y };
189
+
190
+ const physicalLongitudinalSeparation = Math.abs(physicalLongitudinalGap);
191
+ const lateralGap = Math.abs(direction.x * -axis.y + direction.y * axis.x);
192
+ const requiredGap = VEHICLE_LIMITS.carLength * 0.96;
193
+
194
+ if (Math.abs(longitudinalGap) > VEHICLE_LIMITS.carLength * 1.45) return null;
195
+ if (physicalLongitudinalSeparation >= requiredGap) return null;
196
+ if (lateralGap > VEHICLE_LIMITS.carWidth * 0.82) return null;
197
+ if (headingDelta > 0.58) return null;
198
+
199
+ return {
200
+ axis,
201
+ depth: requiredGap - physicalLongitudinalSeparation,
202
+ longitudinal: true,
203
+ };
204
+ }
205
+
206
+ function forwardVector(car) {
207
+ return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
208
+ }
209
+
210
+ function dot(a, b) {
211
+ return a.x * b.x + a.y * b.y;
212
+ }
213
+
214
+ function normalizeVector(vector) {
215
+ const length = Math.hypot(vector.x, vector.y) || 1;
216
+ return { x: vector.x / length, y: vector.y / length };
217
+ }
218
+
219
+ function progressDelta(a, b, trackLength) {
220
+ let delta = a - b;
221
+ if (delta < -trackLength / 2) delta += trackLength;
222
+ if (delta > trackLength / 2) delta -= trackLength;
223
+ return delta;
224
+ }
225
+
226
+ function trimTimingHistory(history, currentTime) {
227
+ const cutoff = currentTime - TIMING_HISTORY_WINDOW_SECONDS;
228
+ while (history.length > 2 && (history[0].time < cutoff || history.length > TIMING_HISTORY_MAX_SAMPLES)) {
229
+ history.shift();
230
+ }
231
+ }
232
+
233
+ function resetTimingHistory(car, currentTime) {
234
+ car.timingHistory = [{ time: currentTime, raceDistance: car.raceDistance }];
235
+ }
236
+
237
+ function recordTimingSample(car, currentTime) {
238
+ if (!Number.isFinite(car.raceDistance)) return;
239
+ if (!Array.isArray(car.timingHistory)) {
240
+ resetTimingHistory(car, currentTime);
241
+ return;
242
+ }
243
+
244
+ const previous = car.timingHistory[car.timingHistory.length - 1];
245
+ if (
246
+ previous &&
247
+ Math.abs(previous.time - currentTime) <= 1e-6 &&
248
+ Math.abs(previous.raceDistance - car.raceDistance) <= 1e-3
249
+ ) {
250
+ return;
251
+ }
252
+
253
+ car.timingHistory.push({ time: currentTime, raceDistance: car.raceDistance });
254
+ trimTimingHistory(car.timingHistory, currentTime);
255
+ }
256
+
257
+ function interpolateTimeAtDistance(history, targetDistance) {
258
+ if (!Array.isArray(history) || history.length < 2) return null;
259
+
260
+ for (let index = history.length - 1; index > 0; index -= 1) {
261
+ const current = history[index];
262
+ const previous = history[index - 1];
263
+ if (targetDistance < previous.raceDistance || targetDistance > current.raceDistance) continue;
264
+
265
+ const coveredDistance = current.raceDistance - previous.raceDistance;
266
+ if (coveredDistance <= 1e-6) return current.time;
267
+ const ratio = (targetDistance - previous.raceDistance) / coveredDistance;
268
+ return previous.time + (current.time - previous.time) * ratio;
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ function estimateGapAheadSeconds(ahead, car, currentTime) {
275
+ if (!ahead) return Infinity;
276
+
277
+ const crossingTime = interpolateTimeAtDistance(ahead.timingHistory, car.raceDistance);
278
+ if (Number.isFinite(crossingTime)) {
279
+ return Math.max(0, currentTime - crossingTime);
280
+ }
281
+
282
+ const speedReference = Math.max((ahead.speed + car.speed) * 0.5, 1);
283
+ return Math.max(0, (ahead.raceDistance - car.raceDistance) / speedReference);
284
+ }
285
+
286
+ function recordDrsDetection(car, zoneId, currentTime) {
287
+ const previous = car.drsDetection?.[zoneId] ?? { passage: 0, time: -Infinity };
288
+ const next = { passage: previous.passage + 1, time: currentTime };
289
+ car.drsDetection = {
290
+ ...(car.drsDetection ?? {}),
291
+ [zoneId]: next,
292
+ };
293
+ return next;
294
+ }
295
+
296
+ function serializeCar(car, rank) {
297
+ return {
298
+ id: car.id,
299
+ code: car.code,
300
+ timingCode: car.timingCode,
301
+ driverNumber: car.driverNumber,
302
+ icon: car.icon,
303
+ raceName: car.raceName,
304
+ name: car.name,
305
+ color: car.color,
306
+ team: car.team ? { ...car.team } : null,
307
+ tire: car.tire,
308
+ personality: { ...car.personality },
309
+ aggression: car.aggression,
310
+ aggressionPercent: Math.round((car.aggression ?? 0) * 100),
311
+ setup: {
312
+ vehicleId: car.vehicleId,
313
+ vehicleName: car.vehicleName,
314
+ vehicleRatings: car.vehicleRatings ? { ...car.vehicleRatings } : null,
315
+ maxSpeedKph: simSpeedToKph(VEHICLE_LIMITS.maxSpeed),
316
+ powerUnitKn: car.powerNewtons / 1000,
317
+ brakeSystemKn: car.brakeNewtons / 1000,
318
+ dragCoefficient: car.dragCoefficient,
319
+ downforceCoefficient: car.downforceCoefficient,
320
+ tireGrip: car.tireGrip,
321
+ tireCare: car.tireCare,
322
+ massKg: car.mass,
323
+ pace: car.pace,
324
+ racecraft: car.racecraft,
325
+ },
326
+ rank,
327
+ classifiedRank: car.classifiedRank ?? rank,
328
+ finished: Boolean(car.finished),
329
+ finishTime: car.finishTime ?? null,
330
+ previousX: car.previousX ?? car.x,
331
+ previousY: car.previousY ?? car.y,
332
+ x: car.x,
333
+ y: car.y,
334
+ previousHeading: car.previousHeading ?? car.heading,
335
+ heading: car.heading,
336
+ steeringAngle: car.steeringAngle,
337
+ yawRate: car.yawRate,
338
+ turnRadius: car.turnRadius,
339
+ speed: car.speed,
340
+ speedKph: simSpeedToKph(car.speed),
341
+ throttle: car.throttle,
342
+ brake: car.brake,
343
+ lateralAcceleration: car.lateralAcceleration,
344
+ progress: car.progress,
345
+ raceDistance: car.raceDistance,
346
+ distanceMeters: simUnitsToMeters(car.raceDistance),
347
+ lap: car.lap,
348
+ gapAhead: car.gapAhead,
349
+ gapAheadMeters: Number.isFinite(car.gapAhead) ? simUnitsToMeters(car.gapAhead) : Infinity,
350
+ gapAheadSeconds: car.gapAheadSeconds,
351
+ intervalAheadSeconds: car.intervalAheadSeconds,
352
+ leaderGapSeconds: car.leaderGapSeconds,
353
+ drsEligible: car.drsEligible,
354
+ drsActive: car.drsActive,
355
+ drsZoneId: car.drsZoneId,
356
+ drsZoneEnabled: car.drsZoneEnabled,
357
+ canAttack: car.canAttack,
358
+ signedOffset: car.trackState?.signedOffset ?? 0,
359
+ crossTrackError: car.trackState?.crossTrackError ?? 0,
360
+ surface: car.trackState?.surface ?? 'track',
361
+ contactCooldown: car.contactCooldown,
362
+ tireEnergy: car.tireEnergy,
363
+ positionSource: 'integrated-vehicle',
364
+ };
365
+ }
366
+
367
+ export class F1RaceSimulation {
368
+ constructor({ seed = 1, drivers = [], totalLaps = DEFAULT_TOTAL_LAPS, rules = {}, track = null, trackSeed = null } = {}) {
369
+ this.seed = seed;
370
+ this.random = createMulberry32(seed);
371
+ const trackDefinition = track ?? (trackSeed == null ? TRACK : createProceduralTrack(trackSeed));
372
+ this.track = buildTrackModel(trackDefinition);
373
+ this.trackSeed = this.track.seed ?? trackSeed;
374
+ this.rules = { ...DEFAULT_RULES, ...rules };
375
+ this.startLightsOutAt = this.rules.startLightCount * this.rules.startLightInterval + this.rules.startLightsOutHold;
376
+ this.totalLaps = totalLaps;
377
+ this.time = 0;
378
+ this.events = [];
379
+ this.raceControl = {
380
+ mode: this.rules.standingStart === false ? 'green' : 'pre-start',
381
+ frozenOrder: null,
382
+ finished: false,
383
+ finishedAt: null,
384
+ winnerId: null,
385
+ classification: [],
386
+ finishOrder: [],
387
+ start: {
388
+ lightCount: this.rules.startLightCount,
389
+ lightsLit: 0,
390
+ lightsOutAt: this.startLightsOutAt,
391
+ released: this.rules.standingStart === false,
392
+ releasedAt: this.rules.standingStart === false ? 0 : null,
393
+ },
394
+ };
395
+ const safetyCarStart = pointAt(this.track, this.rules.safetyCarLeadDistance);
396
+ this.safetyCar = {
397
+ deployed: false,
398
+ progress: this.rules.safetyCarLeadDistance,
399
+ speed: this.rules.safetyCarSpeed,
400
+ previousX: safetyCarStart.x,
401
+ previousY: safetyCarStart.y,
402
+ previousHeading: safetyCarStart.heading,
403
+ x: safetyCarStart.x,
404
+ y: safetyCarStart.y,
405
+ heading: safetyCarStart.heading,
406
+ };
407
+ this.cars = drivers.map((driver, index) => createCar(driver, index, this.random, this.track, {
408
+ standingStart: this.raceControl.mode === 'pre-start',
409
+ }));
410
+ this.recalculateRaceState({ updateDrs: false });
411
+ this.cars.forEach((car) => resetTimingHistory(car, this.time));
412
+ }
413
+
414
+ setSafetyCar(deployed) {
415
+ const next = Boolean(deployed);
416
+ if (this.raceControl.finished) return;
417
+ if (next && this.raceControl.mode === 'pre-start') return;
418
+ if (next === this.safetyCar.deployed) return;
419
+ const ordered = this.orderedCars();
420
+ this.safetyCar.deployed = next;
421
+ this.raceControl.mode = next ? 'safety-car' : 'green';
422
+ this.raceControl.frozenOrder = next ? ordered.map((car) => car.id) : null;
423
+ if (next) {
424
+ const leader = ordered[0];
425
+ const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
426
+ if (this.safetyCar.progress < safetyCarProgress) {
427
+ this.moveSafetyCarTo(safetyCarProgress);
428
+ }
429
+ this.cars.forEach((car) => {
430
+ car.desiredOffset = 0;
431
+ car.drsActive = false;
432
+ car.drsEligible = false;
433
+ car.drsZoneId = null;
434
+ car.drsZoneEnabled = false;
435
+ });
436
+ }
437
+ this.events.unshift({ type: next ? 'safety-car' : 'green-flag', at: this.time });
438
+ }
439
+
440
+ setCarState(id, partial) {
441
+ const car = this.cars.find((item) => item.id === id);
442
+ if (!car) return;
443
+ Object.assign(car, partial);
444
+ if (
445
+ partial.x != null ||
446
+ partial.y != null ||
447
+ partial.progress != null ||
448
+ partial.raceDistance != null
449
+ ) {
450
+ car.gridLocked = false;
451
+ if (this.raceControl.mode === 'pre-start' && this.cars.every((item) => !item.gridLocked)) {
452
+ this.releaseRaceStart();
453
+ }
454
+ }
455
+ car.speed = clamp(car.speed, 0, VEHICLE_LIMITS.maxSpeed);
456
+ car.heading = normalizeAngle(car.heading);
457
+ car.trackState = nearestTrackState(this.track, car);
458
+ const delta = progressDelta(car.trackState.distance, car.progress ?? car.trackState.distance, this.track.length);
459
+ car.raceDistance = (car.raceDistance ?? car.trackState.distance) + delta;
460
+ car.progress = car.trackState.distance;
461
+ car.lap = this.computeLap(car.raceDistance);
462
+ car.drsZoneId = null;
463
+ car.drsZoneEnabled = false;
464
+ car.drsActive = false;
465
+ car.drsEligible = false;
466
+ car.drsDetection = {};
467
+ resetTimingHistory(car, this.time);
468
+ this.recalculateRaceState({ updateDrs: false });
469
+ this.evaluateRaceFinish();
470
+ }
471
+
472
+ setCarControls(id, controls) {
473
+ const car = this.cars.find((item) => item.id === id);
474
+ if (!car) return;
475
+ car.manualControls = controls;
476
+ }
477
+
478
+ clearCarControls(id) {
479
+ const car = this.cars.find((item) => item.id === id);
480
+ if (car) car.manualControls = null;
481
+ }
482
+
483
+ step(dt) {
484
+ const delta = clamp(dt, 0, 1 / 20);
485
+ if (!Number.isFinite(delta) || delta <= 0) return;
486
+
487
+ this.time += delta;
488
+ this.events = [];
489
+ this.updateStartSequence();
490
+ this.recalculateRaceState({ updateDrs: false });
491
+
492
+ if (this.raceControl.mode === 'pre-start' && this.cars.every((car) => car.gridLocked)) {
493
+ this.holdGridCars();
494
+ this.recalculateRaceState({ updateDrs: false });
495
+ return;
496
+ }
497
+
498
+ this.updateSafetyCar(delta);
499
+
500
+ const orderedCars = this.orderedCars();
501
+ const raceContext = this.driverRaceContext(orderedCars);
502
+ orderedCars.forEach((car, index) => {
503
+ car.previousX = car.x;
504
+ car.previousY = car.y;
505
+ car.previousHeading = car.heading;
506
+ car.previousProgress = car.progress;
507
+ const controls = decideDriverControls({
508
+ car,
509
+ orderIndex: index,
510
+ race: raceContext,
511
+ });
512
+ integrateVehiclePhysics(car, controls, delta);
513
+ this.applyRunoffResponse(car);
514
+ car.contactCooldown = Math.max(0, car.contactCooldown - delta);
515
+ });
516
+
517
+ this.resolveCollisions();
518
+ this.recalculateRaceState();
519
+ this.evaluateRaceFinish();
520
+ }
521
+
522
+ snapshot() {
523
+ const ordered = this.orderedCars();
524
+ return {
525
+ time: this.time,
526
+ world: WORLD,
527
+ track: this.track,
528
+ totalLaps: this.totalLaps,
529
+ raceControl: {
530
+ mode: this.raceControl.mode,
531
+ finished: this.raceControl.finished,
532
+ finishedAt: this.raceControl.finishedAt,
533
+ winner: this.getRaceWinnerSnapshot(),
534
+ classification: this.raceControl.classification.map((entry) => ({ ...entry })),
535
+ start: {
536
+ ...this.raceControl.start,
537
+ visible: this.raceControl.mode === 'pre-start' ||
538
+ (this.raceControl.start.releasedAt != null && this.time - this.raceControl.start.releasedAt < 1.45),
539
+ },
540
+ },
541
+ safetyCar: { ...this.safetyCar },
542
+ rules: this.rules,
543
+ events: [...this.events],
544
+ cars: ordered.map((car, index) => serializeCar(car, index + 1)),
545
+ };
546
+ }
547
+
548
+ orderedCars() {
549
+ if (this.safetyCar.deployed && this.raceControl.frozenOrder?.length) {
550
+ const byId = new Map(this.cars.map((car) => [car.id, car]));
551
+ return this.raceControl.frozenOrder.map((id) => byId.get(id)).filter(Boolean);
552
+ }
553
+
554
+ return [...this.cars].sort((a, b) => {
555
+ const delta = b.raceDistance - a.raceDistance;
556
+ return delta === 0 ? a.index - b.index : delta;
557
+ });
558
+ }
559
+
560
+ driverRaceContext(orderedCars = this.orderedCars()) {
561
+ return {
562
+ track: this.track,
563
+ cars: this.cars,
564
+ orderedCars,
565
+ safetyCar: this.safetyCar,
566
+ rules: this.rules,
567
+ };
568
+ }
569
+
570
+ computeAggression(car, orderIndex = Math.max(0, (car.rank ?? 1) - 1)) {
571
+ const personality = car.personality ?? { baseAggression: 0.5, riskTolerance: 0.5, patience: 0.5 };
572
+ if (this.safetyCar.deployed || car.canAttack === false) {
573
+ return clamp(personality.baseAggression * 0.62, 0.08, 0.62);
574
+ }
575
+
576
+ const fieldDepth = Math.max(1, this.cars.length - 1);
577
+ const positionPressure = clamp(orderIndex / fieldDepth, 0, 1);
578
+ const gapPressure = Number.isFinite(car.gapAhead) ? clamp((230 - car.gapAhead) / 230, 0, 1) : 0;
579
+ const tireConfidence = clamp(((car.tireEnergy ?? 100) - 42) / 58, 0, 1);
580
+ const patienceDamping = (1 - gapPressure) * personality.patience * 0.08;
581
+
582
+ return clamp(
583
+ personality.baseAggression
584
+ + positionPressure * 0.26
585
+ + gapPressure * (0.1 + personality.riskTolerance * 0.08)
586
+ - (1 - tireConfidence) * 0.1
587
+ - patienceDamping,
588
+ 0.08,
589
+ 1,
590
+ );
591
+ }
592
+
593
+ updateStartSequence() {
594
+ const start = this.raceControl.start;
595
+ if (this.raceControl.mode !== 'pre-start' || !start || start.released) return;
596
+
597
+ if (this.time >= start.lightsOutAt) {
598
+ this.releaseRaceStart();
599
+ this.events.unshift({ type: 'start-lights-out', at: this.time });
600
+ return;
601
+ }
602
+
603
+ start.lightsLit = clamp(
604
+ Math.floor((this.time + Number.EPSILON) / this.rules.startLightInterval),
605
+ 0,
606
+ start.lightCount,
607
+ );
608
+ }
609
+
610
+ releaseRaceStart() {
611
+ const start = this.raceControl.start;
612
+ this.raceControl.mode = 'green';
613
+ start.lightsLit = 0;
614
+ start.released = true;
615
+ start.releasedAt = this.time;
616
+ this.cars.forEach((car) => {
617
+ const wasGridLocked = car.gridLocked;
618
+ car.gridLocked = false;
619
+ const state = nearestTrackState(this.track, car, car.gridDistance);
620
+ car.trackState = state;
621
+ car.progress = state.distance;
622
+ if (wasGridLocked) car.raceDistance = car.gridDistance;
623
+ car.previousX = car.x;
624
+ car.previousY = car.y;
625
+ car.previousHeading = car.heading;
626
+ });
627
+ }
628
+
629
+ holdGridCars() {
630
+ this.cars.forEach((car) => {
631
+ if (!car.gridLocked) return;
632
+ const gridPoint = pointAt(this.track, car.gridDistance);
633
+ const position = offsetTrackPoint(gridPoint, car.gridOffset);
634
+ car.previousX = position.x;
635
+ car.previousY = position.y;
636
+ car.previousHeading = gridPoint.heading;
637
+ car.x = position.x;
638
+ car.y = position.y;
639
+ car.heading = gridPoint.heading;
640
+ car.speed = 0;
641
+ car.throttle = 0;
642
+ car.brake = 1;
643
+ car.steeringAngle = 0;
644
+ car.yawRate = 0;
645
+ car.turnRadius = Infinity;
646
+ car.progress = gridPoint.distance;
647
+ car.raceDistance = car.gridDistance;
648
+ car.trackState = nearestTrackState(this.track, car, car.gridDistance);
649
+ });
650
+ }
651
+
652
+ updateSafetyCar(dt) {
653
+ if (!this.safetyCar.deployed) return;
654
+ const leader = this.orderedCars()[0];
655
+ const targetProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
656
+ const progress = Math.max(this.safetyCar.progress + this.safetyCar.speed * dt, targetProgress);
657
+ this.moveSafetyCarTo(progress);
658
+ }
659
+
660
+ moveSafetyCarTo(progress) {
661
+ const point = pointAt(this.track, progress);
662
+ this.safetyCar.previousX = this.safetyCar.x;
663
+ this.safetyCar.previousY = this.safetyCar.y;
664
+ this.safetyCar.previousHeading = this.safetyCar.heading;
665
+ this.safetyCar.progress = progress;
666
+ this.safetyCar.x = point.x;
667
+ this.safetyCar.y = point.y;
668
+ this.safetyCar.heading = point.heading;
669
+ }
670
+
671
+ applyRunoffResponse(car) {
672
+ const state = nearestTrackState(this.track, car, car.progress);
673
+ const signedLimit = this.track.width / 2 + this.track.gravelWidth + this.track.runoffWidth;
674
+ const overshoot = Math.abs(state.signedOffset) - signedLimit;
675
+ if (overshoot <= 0) {
676
+ car.trackState = state;
677
+ return;
678
+ }
679
+
680
+ const side = Math.sign(state.signedOffset) || 1;
681
+ car.x -= state.normalX * side * overshoot;
682
+ car.y -= state.normalY * side * overshoot;
683
+ car.speed = clamp(car.speed * clamp(1 - overshoot * 0.012, 0.22, 0.86), 0, VEHICLE_LIMITS.maxSpeed);
684
+ car.heading = normalizeAngle(car.heading - side * clamp(overshoot * 0.0028, 0.018, 0.08));
685
+ car.trackState = nearestTrackState(this.track, car, state.distance);
686
+ }
687
+
688
+ recalculateRaceState({ updateDrs = true } = {}) {
689
+ this.cars.forEach((car) => {
690
+ if (car.gridLocked) {
691
+ const gridPoint = pointAt(this.track, car.gridDistance);
692
+ car.trackState = nearestTrackState(this.track, car, car.gridDistance);
693
+ car.progress = gridPoint.distance;
694
+ car.raceDistance = car.gridDistance;
695
+ car.lap = 1;
696
+ return;
697
+ }
698
+
699
+ car.trackState = nearestTrackState(this.track, car, car.progress);
700
+ const previousProgress = car.progress ?? car.trackState.distance;
701
+ const delta = progressDelta(car.trackState.distance, previousProgress, this.track.length);
702
+ car.raceDistance = (car.raceDistance ?? previousProgress) + delta;
703
+ car.progress = car.trackState.distance;
704
+ car.lap = this.computeLap(car.raceDistance);
705
+ });
706
+
707
+ this.cars.forEach((car) => recordTimingSample(car, this.time));
708
+
709
+ const ordered = this.orderedCars();
710
+ ordered.forEach((car, index) => {
711
+ const ahead = ordered[index - 1];
712
+ const gap = ahead ? ahead.raceDistance - car.raceDistance : Infinity;
713
+ car.rank = index + 1;
714
+ car.gapAhead = gap;
715
+ car.gapAheadSeconds = Number.isFinite(gap) ? estimateGapAheadSeconds(ahead, car, this.time) : Infinity;
716
+ car.intervalAheadSeconds = car.gapAheadSeconds;
717
+ car.leaderGapSeconds = ahead ? ahead.leaderGapSeconds + car.gapAheadSeconds : 0;
718
+ car.canAttack = !this.safetyCar.deployed && !car.finished;
719
+ car.aggression = this.computeAggression(car, index);
720
+ if (updateDrs) this.updateDrsLatch(car, ahead, index);
721
+ });
722
+ this.evaluateRaceFinish();
723
+ }
724
+
725
+ evaluateRaceFinish() {
726
+ if (this.raceControl.finished) return;
727
+ if (this.raceControl.mode === 'pre-start') return;
728
+
729
+ const ordered = this.orderedCars();
730
+ const newlyFinished = ordered.filter((car) => !car.finished && car.raceDistance >= this.finishDistance);
731
+ if (newlyFinished.length === 0) return;
732
+
733
+ newlyFinished.forEach((car) => {
734
+ car.finished = true;
735
+ car.finishTime = this.time;
736
+ car.classifiedRank = this.raceControl.finishOrder.length + 1;
737
+ this.raceControl.finishOrder.push(car.id);
738
+ if (!this.raceControl.winnerId) this.raceControl.winnerId = car.id;
739
+ car.drsActive = false;
740
+ car.drsEligible = false;
741
+ car.drsZoneId = null;
742
+ car.drsZoneEnabled = false;
743
+ car.canAttack = false;
744
+ this.events.unshift({
745
+ type: 'car-finish',
746
+ at: this.time,
747
+ carId: car.id,
748
+ rank: car.classifiedRank,
749
+ winnerId: this.raceControl.winnerId,
750
+ });
751
+ });
752
+
753
+ if (!this.cars.every((car) => car.finished)) return;
754
+
755
+ const classification = this.buildClassificationFromFinishOrder();
756
+ this.raceControl.mode = 'safety-car';
757
+ this.raceControl.finished = true;
758
+ this.raceControl.finishedAt = this.time;
759
+ this.raceControl.classification = classification;
760
+ this.raceControl.frozenOrder = classification.map((entry) => entry.id);
761
+ this.safetyCar.deployed = true;
762
+ const leader = this.cars.find((car) => car.id === this.raceControl.winnerId) ?? ordered[0];
763
+ const safetyCarProgress = (leader?.raceDistance ?? 0) + this.rules.safetyCarLeadDistance;
764
+ if (this.safetyCar.progress < safetyCarProgress) {
765
+ this.moveSafetyCarTo(safetyCarProgress);
766
+ }
767
+ this.cars.forEach((car) => {
768
+ const classified = classification.find((entry) => entry.id === car.id);
769
+ car.classifiedRank = classified?.rank ?? car.rank;
770
+ car.desiredOffset = 0;
771
+ car.drsActive = false;
772
+ car.drsEligible = false;
773
+ car.drsZoneId = null;
774
+ car.drsZoneEnabled = false;
775
+ car.canAttack = false;
776
+ });
777
+ this.events.unshift({
778
+ type: 'race-finish',
779
+ at: this.time,
780
+ winnerId: this.raceControl.winnerId,
781
+ classification: classification.map((entry) => ({ id: entry.id, rank: entry.rank })),
782
+ });
783
+ }
784
+
785
+ buildClassificationFromFinishOrder() {
786
+ const byId = new Map(this.cars.map((car) => [car.id, car]));
787
+ const ordered = this.raceControl.finishOrder
788
+ .map((id) => byId.get(id))
789
+ .filter(Boolean);
790
+ return this.buildClassification(ordered);
791
+ }
792
+
793
+ buildClassification(ordered = this.orderedCars()) {
794
+ const leaderDistance = ordered[0]?.raceDistance ?? 0;
795
+ return ordered.map((car, index) => ({
796
+ id: car.id,
797
+ code: car.code,
798
+ timingCode: car.timingCode,
799
+ name: car.name,
800
+ rank: index + 1,
801
+ raceDistance: car.raceDistance,
802
+ distanceMeters: simUnitsToMeters(car.raceDistance),
803
+ lap: this.computeLap(car.raceDistance),
804
+ lapsCompleted: clamp(Math.floor(Math.max(0, car.raceDistance) / this.track.length), 0, this.totalLaps),
805
+ gapMeters: simUnitsToMeters(Math.max(0, leaderDistance - car.raceDistance)),
806
+ gapSeconds: index === 0 ? 0 : car.leaderGapSeconds,
807
+ intervalSeconds: index === 0 ? 0 : car.intervalAheadSeconds,
808
+ finished: car.raceDistance >= this.finishDistance,
809
+ finishTime: car.finishTime ?? (car.raceDistance >= this.finishDistance ? this.time : null),
810
+ }));
811
+ }
812
+
813
+ getRaceWinnerSnapshot() {
814
+ if (!this.raceControl.winnerId) return null;
815
+ const car = this.cars.find((item) => item.id === this.raceControl.winnerId);
816
+ if (!car) return null;
817
+ const rank = car.classifiedRank ?? car.rank ?? 1;
818
+ return serializeCar(car, rank);
819
+ }
820
+
821
+ updateDrsLatch(car, ahead, orderIndex) {
822
+ if (this.safetyCar.deployed || car.finished) {
823
+ car.drsEligible = false;
824
+ car.drsActive = false;
825
+ car.drsZoneId = null;
826
+ car.drsZoneEnabled = false;
827
+ return;
828
+ }
829
+
830
+ const previousProgress = car.previousProgress ?? car.progress;
831
+ const currentZone = car.drsZoneId
832
+ ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
833
+ : null;
834
+
835
+ if (currentZone && !isProgressInZone(this.track, car.progress, currentZone)) {
836
+ car.drsZoneId = null;
837
+ car.drsZoneEnabled = false;
838
+ }
839
+
840
+ if (!car.drsZoneId) {
841
+ const crossedZone = this.track.drsZones.find((zone) => (
842
+ crossesDistance(previousProgress, car.progress, zone.start, this.track.length)
843
+ ));
844
+ if (crossedZone) {
845
+ car.drsZoneId = crossedZone.id;
846
+ const crossing = recordDrsDetection(car, crossedZone.id, this.time);
847
+ const aheadCrossing = ahead?.drsDetection?.[crossedZone.id];
848
+ car.drsZoneEnabled = Boolean(
849
+ orderIndex > 0 &&
850
+ aheadCrossing &&
851
+ aheadCrossing.passage === crossing.passage &&
852
+ crossing.time >= aheadCrossing.time &&
853
+ crossing.time - aheadCrossing.time <= this.rules.drsDetectionSeconds + 1e-6
854
+ );
855
+ }
856
+ }
857
+
858
+ const activeZone = car.drsZoneId
859
+ ? this.track.drsZones.find((zone) => zone.id === car.drsZoneId)
860
+ : null;
861
+ const inLatchedZone = activeZone ? isProgressInZone(this.track, car.progress, activeZone) : false;
862
+ car.drsEligible = Boolean(car.drsZoneEnabled && inLatchedZone);
863
+ car.drsActive = car.drsEligible;
864
+ }
865
+
866
+ resolveCollisions() {
867
+ const reportedContacts = new Set();
868
+
869
+ for (let pass = 0; pass < 3; pass += 1) {
870
+ for (let i = 0; i < this.cars.length; i += 1) {
871
+ for (let j = i + 1; j < this.cars.length; j += 1) {
872
+ const first = this.cars[i];
873
+ const second = this.cars[j];
874
+ const collision = detectObbCollision(first, second) ?? detectLongitudinalCollision(first, second);
875
+ if (!collision) continue;
876
+
877
+ const correction = Math.min(
878
+ collision.depth / 2 + (collision.longitudinal ? 0.35 : 0.65),
879
+ MAX_COLLISION_CORRECTION,
880
+ );
881
+ first.x -= collision.axis.x * correction;
882
+ first.y -= collision.axis.y * correction;
883
+ second.x += collision.axis.x * correction;
884
+ second.y += collision.axis.y * correction;
885
+
886
+ this.applyContactVelocityResponse(first, second, collision.axis);
887
+
888
+ const yawNudge = clamp(collision.depth * 0.0025, 0.008, 0.035);
889
+ const freshContact = first.contactCooldown <= 0 && second.contactCooldown <= 0;
890
+ first.heading = normalizeAngle(first.heading - collision.axis.y * yawNudge);
891
+ second.heading = normalizeAngle(second.heading + collision.axis.y * yawNudge);
892
+ first.contactCooldown = 1;
893
+ second.contactCooldown = 1;
894
+
895
+ const contactKey = `${first.id}:${second.id}`;
896
+ if (freshContact && pass === 0 && !reportedContacts.has(contactKey)) {
897
+ reportedContacts.add(contactKey);
898
+ this.events.unshift({ type: 'contact', at: this.time, carId: first.id, otherCarId: second.id });
899
+ }
900
+ }
901
+ }
902
+ }
903
+ }
904
+
905
+ applyContactVelocityResponse(first, second, axis) {
906
+ const firstForward = forwardVector(first);
907
+ const secondForward = forwardVector(second);
908
+ const firstNormal = dot(firstForward, axis);
909
+ const secondNormal = dot(secondForward, axis);
910
+ const relativeNormalVelocity = second.speed * secondNormal - first.speed * firstNormal;
911
+
912
+ if (relativeNormalVelocity < 0) {
913
+ const impulse = clamp(-relativeNormalVelocity * (0.34 + this.rules.collisionRestitution), 0, 16);
914
+ if (firstNormal > 0) first.speed = clamp(first.speed - impulse * firstNormal, 0, VEHICLE_LIMITS.maxSpeed);
915
+ if (secondNormal < 0) second.speed = clamp(second.speed + impulse * secondNormal, 0, VEHICLE_LIMITS.maxSpeed);
916
+ }
917
+
918
+ first.speed = clamp(first.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
919
+ second.speed = clamp(second.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
920
+ }
921
+
922
+ computeLap(raceDistance) {
923
+ return clamp(Math.floor(Math.max(0, raceDistance) / this.track.length) + 1, 1, this.totalLaps);
924
+ }
925
+
926
+ get finishDistance() {
927
+ return this.track.length * this.totalLaps;
928
+ }
929
+ }
930
+
931
+ export function createRaceSimulation(options = {}) {
932
+ return new F1RaceSimulation(options);
933
+ }