@inventure71/paddockjs 4.0.0 → 4.2.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 (46) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/INSTALL_AND_UPDATE.md +14 -1
  3. package/README.md +33 -1
  4. package/docs/architecture.md +3 -1
  5. package/docs/component_inventory.md +5 -4
  6. package/docs/composable-layouts.md +2 -0
  7. package/docs/data_contract.md +5 -1
  8. package/docs/getting-started.md +33 -0
  9. package/docs/index.md +4 -2
  10. package/docs/loading.md +141 -0
  11. package/docs/system_specs.md +15 -2
  12. package/docs/testing-reliability-guide.md +62 -0
  13. package/package.json +16 -9
  14. package/src/data/index.d.ts +64 -1
  15. package/src/environment/index.d.ts +75 -12
  16. package/src/index.d.ts +72 -6
  17. package/src/placeholder/index.d.ts +11 -0
  18. package/src/placeholder/index.js +61 -0
  19. package/src/placeholder.css +72 -0
  20. package/src/simulation/collisionGeometry.js +7 -9
  21. package/src/simulation/driver/driverController.js +2 -2
  22. package/src/simulation/driver/edgeRecovery.js +2 -2
  23. package/src/simulation/driver/racingControls.js +35 -25
  24. package/src/simulation/driver/racingLinePlan.js +6 -5
  25. package/src/simulation/driver/rejoinControls.js +3 -3
  26. package/src/simulation/driver/trafficScan.js +48 -22
  27. package/src/simulation/participants/participantInteractions.js +24 -15
  28. package/src/simulation/race/raceStep.js +19 -10
  29. package/src/simulation/race/safetyCar.js +2 -1
  30. package/src/simulation/rules/pitLaneSpeedingSteward.js +1 -1
  31. package/src/simulation/rules/rulesReview.js +19 -9
  32. package/src/simulation/rules/trackLimitsSteward.js +2 -2
  33. package/src/simulation/timing/sectorPerformance.js +11 -3
  34. package/src/simulation/track/spatialQueries.js +80 -11
  35. package/src/simulation/track/trackModel.js +10 -1
  36. package/src/simulation/vehicle/contactResolution.js +4 -4
  37. package/src/simulation/vehicle/vehicleGeometry.js +87 -49
  38. package/src/simulation/vehicle/vehiclePhysics.js +7 -2
  39. package/src/simulation/vehicle/vehicleSnapshots.js +5 -2
  40. package/src/simulation/vehicle/vehicleState.js +3 -2
  41. package/src/ui/raceCanvasTemplate.js +3 -2
  42. package/src/ui/raceControlsTemplate.js +1 -0
  43. package/src/ui/telemetryDrawerTemplate.js +1 -0
  44. package/src/ui/telemetryTemplates.js +1 -0
  45. package/examples/train-basic-policy.mjs +0 -276
  46. package/examples/trainingData.mjs +0 -133
@@ -1,7 +1,7 @@
1
1
  import { clamp, normalizeAngle } from '../simMath.js';
2
2
  import { kphToSimSpeed, metersToSimUnits, simUnitsToMeters } from '../units.js';
3
- import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
4
- import { VEHICLE_LIMITS } from '../vehicle/vehiclePhysics.js';
3
+ import { pointAtInto, sampleHeadingAt, sampleHeadingCurvatureAtInto } from '../track/trackModel.js';
4
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
5
5
  import { BRAKING_LOOKAHEAD_MAX_DISTANCE, CURVATURE_LOOKAHEAD_SAMPLES, EDGE_RECOVERY_MIN_LOOKAHEAD, LOOKAHEAD_BASE_DISTANCE, LOOKAHEAD_MAX_DISTANCE, OVERTAKE_LATERAL_MIN, PREVIEW_CURVATURE_CAP, TRAFFIC_GAP_AHEAD, TRAFFIC_SIDE_GAP } from './driverControlConstants.js';
6
6
  import { createDriverInput } from './driverInput.js';
7
7
  import { angleToPoint } from './driverMath.js';
@@ -10,15 +10,14 @@ import { calculateActualOverlapPenalty, calculatePlannedTrafficPenalty, planRaci
10
10
  import { analyzeTrackEdgeMotion } from './recoveryDynamics.js';
11
11
 
12
12
  export function decideRacingControls(car, orderIndex, race) {
13
- if (race.physicsMode === 'advanced') {
13
+ if (isSimulatorPhysicsMode(race.physicsMode)) {
14
14
  return decideSimulatorRacingControls(car, orderIndex, race);
15
15
  }
16
16
  return decideArcadeRacingControls(car, orderIndex, race);
17
17
  }
18
18
 
19
- export function decideArcadeRacingControls(car, orderIndex, race) {
20
- return decideRacingControlsForMode(car, orderIndex, race, {
21
- simulatorMode: false,
19
+ const ARCADE_CONTROL_PROFILE = {
20
+ simulatorMode: false,
22
21
  baseLookaheadSpeedFactor: (aggression) => 1.62 - aggression * 0.12,
23
22
  previewLookaheadSpeedFactor: 3.15,
24
23
  edgeLookaheadPenaltyMeters: 18,
@@ -73,12 +72,14 @@ export function decideArcadeRacingControls(car, orderIndex, race) {
73
72
  recoveryHeadingThrottleStart: 0.38,
74
73
  throttleResponseKph: 16,
75
74
  throttleScale: () => 1,
76
- });
75
+ };
76
+
77
+ export function decideArcadeRacingControls(car, orderIndex, race) {
78
+ return decideRacingControlsForMode(car, orderIndex, race, ARCADE_CONTROL_PROFILE);
77
79
  }
78
80
 
79
- export function decideSimulatorRacingControls(car, orderIndex, race) {
80
- return decideRacingControlsForMode(car, orderIndex, race, {
81
- simulatorMode: true,
81
+ const SIMULATOR_CONTROL_PROFILE = {
82
+ simulatorMode: true,
82
83
  baseLookaheadSpeedFactor: (aggression) => 2.45 - aggression * 0.06,
83
84
  previewLookaheadSpeedFactor: 6.15,
84
85
  edgeLookaheadPenaltyMeters: 34,
@@ -141,7 +142,10 @@ export function decideSimulatorRacingControls(car, orderIndex, race) {
141
142
  0,
142
143
  1,
143
144
  ),
144
- });
145
+ };
146
+
147
+ export function decideSimulatorRacingControls(car, orderIndex, race) {
148
+ return decideRacingControlsForMode(car, orderIndex, race, SIMULATOR_CONTROL_PROFILE);
145
149
  }
146
150
 
147
151
  function decideRacingControlsForMode(car, orderIndex, race, profile) {
@@ -170,7 +174,7 @@ function decideRacingControlsForMode(car, orderIndex, race, profile) {
170
174
  EDGE_RECOVERY_MIN_LOOKAHEAD,
171
175
  LOOKAHEAD_MAX_DISTANCE,
172
176
  );
173
- const targetBase = pointAt(race.track, car.progress + lookahead);
177
+ const targetBase = pointAtInto(race.track, car.progress + lookahead, TARGET_BASE_SCRATCH);
174
178
  const curvature = Math.max(
175
179
  car.trackState.curvature,
176
180
  targetBase.curvature,
@@ -189,8 +193,9 @@ function decideRacingControlsForMode(car, orderIndex, race, profile) {
189
193
  -edgeGuard.recoveryOffset,
190
194
  edgeGuard.recoveryOffset,
191
195
  );
192
- const target = offsetTrackPoint(targetBase, targetOffset);
193
- const angleError = angleToPoint(car, target);
196
+ TARGET_POINT_SCRATCH.x = targetBase.x + targetBase.normalX * targetOffset;
197
+ TARGET_POINT_SCRATCH.y = targetBase.y + targetBase.normalY * targetOffset;
198
+ const angleError = angleToPoint(car, TARGET_POINT_SCRATCH);
194
199
  const headingError = normalizeAngle(targetBase.heading - car.heading);
195
200
  const lateralError = car.trackState?.signedOffset ?? 0;
196
201
  const outwardDriftAngle = simulatorMode &&
@@ -363,15 +368,20 @@ function decideRacingControlsForMode(car, orderIndex, race, profile) {
363
368
  .controls();
364
369
  }
365
370
 
371
+ const LOOKAHEAD_SAMPLE_SCRATCH = { heading: 0, curvature: 0 };
372
+ const TARGET_BASE_SCRATCH = { x: 0, y: 0, heading: 0, normalX: 0, normalY: 0, curvature: 0, distance: 0 };
373
+ const TARGET_POINT_SCRATCH = { x: 0, y: 0 };
374
+
366
375
  export function maxLookaheadCurvature(track, progress, lookahead) {
367
376
  let maximum = 0;
368
- let previous = pointAt(track, progress);
377
+ const sample = LOOKAHEAD_SAMPLE_SCRATCH;
378
+ let previousHeading = sampleHeadingCurvatureAtInto(track, progress, sample).heading;
379
+ const segmentDistance = Math.max(1, lookahead / CURVATURE_LOOKAHEAD_SAMPLES);
369
380
  for (let index = 1; index <= CURVATURE_LOOKAHEAD_SAMPLES; index += 1) {
370
- const sample = pointAt(track, progress + lookahead * (index / CURVATURE_LOOKAHEAD_SAMPLES));
371
- const segmentDistance = Math.max(1, lookahead / CURVATURE_LOOKAHEAD_SAMPLES);
372
- const segmentCurvature = Math.abs(normalizeAngle(sample.heading - previous.heading)) / segmentDistance;
381
+ sampleHeadingCurvatureAtInto(track, progress + lookahead * (index / CURVATURE_LOOKAHEAD_SAMPLES), sample);
382
+ const segmentCurvature = Math.abs(normalizeAngle(sample.heading - previousHeading)) / segmentDistance;
373
383
  maximum = Math.max(maximum, Math.abs(sample.curvature ?? 0), Math.min(segmentCurvature, PREVIEW_CURVATURE_CAP));
374
- previous = sample;
384
+ previousHeading = sample.heading;
375
385
  }
376
386
  return maximum;
377
387
  }
@@ -380,16 +390,16 @@ export function calculateRacingLineOffset(car, race, lookahead, curvature, edgeG
380
390
  const cornerStrength = clamp((curvature - 0.00012) / 0.00095, 0, 1);
381
391
  if (cornerStrength <= 0) return 0;
382
392
 
383
- const entry = pointAt(race.track, car.progress - lookahead * 0.35);
384
- const apex = pointAt(race.track, car.progress + lookahead * 0.55);
385
- const exit = pointAt(race.track, car.progress + lookahead * 1.25);
386
- const signedEntry = normalizeAngle(apex.heading - entry.heading);
387
- const signedExit = normalizeAngle(exit.heading - apex.heading);
393
+ const entryHeading = sampleHeadingAt(race.track, car.progress - lookahead * 0.35);
394
+ const apexHeading = sampleHeadingAt(race.track, car.progress + lookahead * 0.55);
395
+ const exitHeading = sampleHeadingAt(race.track, car.progress + lookahead * 1.25);
396
+ const signedEntry = normalizeAngle(apexHeading - entryHeading);
397
+ const signedExit = normalizeAngle(exitHeading - apexHeading);
388
398
  const turnDirection = Math.sign(Math.abs(signedEntry) > Math.abs(signedExit) ? signedEntry : signedExit);
389
399
  if (turnDirection === 0) return 0;
390
400
 
391
401
  const aggression = car.aggression ?? car.personality?.baseAggression ?? 0.5;
392
- const simulatorMode = race.physicsMode === 'advanced';
402
+ const simulatorMode = isSimulatorPhysicsMode(race.physicsMode);
393
403
  const safeEdge = race.track.width / 2 - VEHICLE_LIMITS.carWidth *
394
404
  (simulatorMode ? 2.05 - aggression * 0.08 : 1.15 - aggression * 0.12);
395
405
  const apexOffset = turnDirection * safeEdge *
@@ -18,15 +18,16 @@ export function planRacingLine(car, orderIndex, race) {
18
18
  let bestOffset = currentOffset;
19
19
  let bestScore = -Infinity;
20
20
 
21
- LANE_OFFSETS.forEach((rawOffset) => {
22
- const offset = clamp(rawOffset, -trackLimit, trackLimit);
21
+ for (let laneIndex = 0; laneIndex < LANE_OFFSETS.length; laneIndex += 1) {
22
+ const offset = clamp(LANE_OFFSETS[laneIndex], -trackLimit, trackLimit);
23
23
  const edgeClearance = trackLimit - Math.abs(offset);
24
24
  let score = 80;
25
25
  score -= Math.abs(offset - preferred) * 0.18;
26
26
  score -= Math.abs(offset - currentOffset) * (0.09 - aggression * 0.025);
27
27
  score -= Math.max(0, LANE_EDGE_CLEARANCE_TARGET - edgeClearance) * (0.92 - aggression * 0.42);
28
28
 
29
- traffic.forEach((entry) => {
29
+ for (let trafficIndex = 0; trafficIndex < traffic.length; trafficIndex += 1) {
30
+ const entry = traffic[trafficIndex];
30
31
  const lateral = Math.abs(entry.signedOffset - offset);
31
32
  if (entry.gap > 0 && entry.gap < TRAFFIC_GAP_AHEAD) {
32
33
  const overlapRisk = clamp(TRAFFIC_SIDE_OVERLAP - lateral, 0, TRAFFIC_SIDE_OVERLAP);
@@ -38,7 +39,7 @@ export function planRacingLine(car, orderIndex, race) {
38
39
  const sideOverlapRisk = clamp(TRAFFIC_SIDE_OVERLAP - lateral, 0, TRAFFIC_SIDE_OVERLAP);
39
40
  score -= sideOverlapRisk * (TRAFFIC_SIDE_OVERLAP + entry.gap) * 0.052 * (1 - riskTolerance * 0.22);
40
41
  }
41
- });
42
+ }
42
43
 
43
44
  if (ahead && car.gapAhead < TRAFFIC_GAP_AHEAD) {
44
45
  const passSide = attackPlan?.targetOffset ?? clamp(
@@ -73,7 +74,7 @@ export function planRacingLine(car, orderIndex, race) {
73
74
  bestScore = score;
74
75
  bestOffset = offset;
75
76
  }
76
- });
77
+ }
77
78
 
78
79
  const laneChangeRate = metersToSimUnits(0.95 + car.racecraft * 0.32 + aggression * 0.78);
79
80
  car.desiredOffset = currentOffset + clamp(bestOffset - currentOffset, -laneChangeRate, laneChangeRate);
@@ -4,11 +4,11 @@ import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
4
4
  import { REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_MAX } from './driverControlConstants.js';
5
5
  import { angleToPoint } from './driverMath.js';
6
6
  import { createDriverInput } from './driverInput.js';
7
- import { VEHICLE_LIMITS } from '../vehicle/vehiclePhysics.js';
7
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
8
8
  import { analyzeTrackEdgeMotion } from './recoveryDynamics.js';
9
9
 
10
10
  export function decideRejoinControls(car, race) {
11
- if (race.physicsMode === 'advanced') {
11
+ if (isSimulatorPhysicsMode(race.physicsMode)) {
12
12
  return decideSimulatorRejoinControls(car, race);
13
13
  }
14
14
  return decideArcadeRejoinControls(car, race);
@@ -103,7 +103,7 @@ export function decideSimulatorRejoinControls(car, race) {
103
103
  }
104
104
 
105
105
  function decideRejoinControlsForMode(car, race, profile) {
106
- const simulatorMode = race.physicsMode === 'advanced';
106
+ const simulatorMode = isSimulatorPhysicsMode(race.physicsMode);
107
107
  const lookahead = clamp(car.speed * (profile.simulatorMode ? 0.58 : 0.72) + REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_MAX);
108
108
  const edgeMotion = analyzeTrackEdgeMotion(car, race);
109
109
  const lowSpeedOffTrack = !car.trackState.onTrack && car.speed < kphToSimSpeed(24);
@@ -2,25 +2,43 @@ import { metersToSimUnits } from '../units.js';
2
2
  import { DEFEND_REAR_GAP, TRAFFIC_GAP_AHEAD, TRAFFIC_SIDE_GAP, TRAFFIC_REAR_WINDOW } from './driverControlConstants.js';
3
3
  import { VEHICLE_LIMITS } from '../vehicle/vehiclePhysics.js';
4
4
 
5
+ const TRAFFIC_REAR_SCAN_WINDOW = metersToSimUnits(39);
6
+ const TRAFFIC_FORWARD_SCAN_WINDOW = metersToSimUnits(134);
7
+ const TRAFFIC_BESIDE_WINDOW = metersToSimUnits(28);
8
+
9
+ // Entries are reused across calls: drivers consume the scan synchronously
10
+ // within a single control decision, so a module-level pool avoids re-allocating
11
+ // the candidate list for every car on every step.
12
+ const TRAFFIC_SCRATCH = [];
13
+ const TRAFFIC_ENTRY_POOL = [];
14
+
5
15
  export function scanNearbyTraffic(car, race) {
6
- return race.cars
7
- .filter((other) => other !== car)
8
- .map((other) => ({
9
- car: other,
10
- gap: other.raceDistance - car.raceDistance,
11
- signedOffset: other.trackState?.signedOffset ?? 0,
12
- speed: other.speed,
13
- }))
14
- .filter((entry) => entry.gap > -metersToSimUnits(39) && entry.gap < metersToSimUnits(134));
16
+ const traffic = TRAFFIC_SCRATCH;
17
+ traffic.length = 0;
18
+ const cars = race.cars;
19
+ for (let index = 0; index < cars.length; index += 1) {
20
+ const other = cars[index];
21
+ if (other === car) continue;
22
+ const gap = other.raceDistance - car.raceDistance;
23
+ if (gap <= -TRAFFIC_REAR_SCAN_WINDOW || gap >= TRAFFIC_FORWARD_SCAN_WINDOW) continue;
24
+ const entry = TRAFFIC_ENTRY_POOL[traffic.length] ??= { car: null, gap: 0, signedOffset: 0, speed: 0 };
25
+ entry.car = other;
26
+ entry.gap = gap;
27
+ entry.signedOffset = other.trackState?.signedOffset ?? 0;
28
+ entry.speed = other.speed;
29
+ traffic.push(entry);
30
+ }
31
+ return traffic;
15
32
  }
16
33
 
17
34
  export function findDefensiveThreat(traffic) {
18
35
  let closest = null;
19
36
 
20
- traffic.forEach((entry) => {
21
- if (entry.gap >= -VEHICLE_LIMITS.carLength * 1.1 || entry.gap < -DEFEND_REAR_GAP) return;
37
+ for (let index = 0; index < traffic.length; index += 1) {
38
+ const entry = traffic[index];
39
+ if (entry.gap >= -VEHICLE_LIMITS.carLength * 1.1 || entry.gap < -DEFEND_REAR_GAP) continue;
22
40
  if (!closest || entry.gap > closest.gap) closest = entry;
23
- });
41
+ }
24
42
 
25
43
  return closest;
26
44
  }
@@ -28,24 +46,32 @@ export function findDefensiveThreat(traffic) {
28
46
  export function findLaneTrafficAhead(traffic, offset, maxDistance) {
29
47
  let closest = null;
30
48
 
31
- traffic.forEach((entry) => {
32
- if (entry.gap <= 0 || entry.gap > maxDistance) return;
33
- if (Math.abs(entry.signedOffset - offset) > TRAFFIC_SIDE_GAP) return;
49
+ for (let index = 0; index < traffic.length; index += 1) {
50
+ const entry = traffic[index];
51
+ if (entry.gap <= 0 || entry.gap > maxDistance) continue;
52
+ if (Math.abs(entry.signedOffset - offset) > TRAFFIC_SIDE_GAP) continue;
34
53
  if (!closest || entry.gap < closest.gap) closest = entry;
35
- });
54
+ }
36
55
 
37
56
  return closest;
38
57
  }
39
58
 
40
59
  export function findLaneTrafficBeside(traffic, offset) {
41
60
  let closest = null;
61
+ let closestLateral = 0;
62
+ let closestRisk = -Infinity;
42
63
 
43
- traffic.forEach((entry) => {
64
+ for (let index = 0; index < traffic.length; index += 1) {
65
+ const entry = traffic[index];
44
66
  const lateral = Math.abs(entry.signedOffset - offset);
45
- if (Math.abs(entry.gap) > metersToSimUnits(28) || lateral > TRAFFIC_SIDE_GAP) return;
46
- const risk = (metersToSimUnits(28) - Math.abs(entry.gap)) + (TRAFFIC_SIDE_GAP - lateral);
47
- if (!closest || risk > closest.risk) closest = { ...entry, lateral, risk };
48
- });
67
+ if (Math.abs(entry.gap) > TRAFFIC_BESIDE_WINDOW || lateral > TRAFFIC_SIDE_GAP) continue;
68
+ const risk = (TRAFFIC_BESIDE_WINDOW - Math.abs(entry.gap)) + (TRAFFIC_SIDE_GAP - lateral);
69
+ if (!closest || risk > closestRisk) {
70
+ closest = entry;
71
+ closestLateral = lateral;
72
+ closestRisk = risk;
73
+ }
74
+ }
49
75
 
50
- return closest;
76
+ return closest ? { ...closest, lateral: closestLateral, risk: closestRisk } : null;
51
77
  }
@@ -41,14 +41,6 @@ export const PARTICIPANT_INTERACTION_PROFILES = Object.freeze({
41
41
  }),
42
42
  });
43
43
 
44
- const INTERACTION_FLAGS = Object.freeze([
45
- 'collidable',
46
- 'detectableByRays',
47
- 'detectableAsNearby',
48
- 'blocksPitLane',
49
- 'affectsRaceOrder',
50
- ]);
51
-
52
44
  export function normalizeParticipantInteractions(options = {}) {
53
45
  const defaultProfile = resolveProfileName(options.defaultProfile);
54
46
  const defaultInteraction = { ...PARTICIPANT_INTERACTION_PROFILES[defaultProfile] };
@@ -73,11 +65,15 @@ export function attachParticipantInteractions(cars = [], options = {}) {
73
65
 
74
66
  export function resolveInteraction(override = {}, fallbackProfile = 'normal') {
75
67
  const profile = resolveProfileName(override?.profile ?? fallbackProfile);
76
- const resolved = { ...PARTICIPANT_INTERACTION_PROFILES[profile] };
77
- INTERACTION_FLAGS.forEach((flag) => {
78
- if (override?.[flag] != null) resolved[flag] = Boolean(override[flag]);
79
- });
80
- return resolved;
68
+ const base = PARTICIPANT_INTERACTION_PROFILES[profile];
69
+ return {
70
+ profile,
71
+ collidable: override?.collidable != null ? Boolean(override.collidable) : base.collidable,
72
+ detectableByRays: override?.detectableByRays != null ? Boolean(override.detectableByRays) : base.detectableByRays,
73
+ detectableAsNearby: override?.detectableAsNearby != null ? Boolean(override.detectableAsNearby) : base.detectableAsNearby,
74
+ blocksPitLane: override?.blocksPitLane != null ? Boolean(override.blocksPitLane) : base.blocksPitLane,
75
+ affectsRaceOrder: override?.affectsRaceOrder != null ? Boolean(override.affectsRaceOrder) : base.affectsRaceOrder,
76
+ };
81
77
  }
82
78
 
83
79
  export function canCollide(first, second) {
@@ -110,8 +106,21 @@ export function affectsRaceOrder(car) {
110
106
  }
111
107
 
112
108
  export function serializeParticipantInteraction(interaction) {
113
- const normalized = resolveInteraction(interaction ?? {}, interaction?.profile ?? 'normal');
114
- return { ...normalized };
109
+ return resolveInteraction(interaction ?? {}, interaction?.profile ?? 'normal');
110
+ }
111
+
112
+ const SERIALIZED_INTERACTION_SOURCE = Symbol('serializedInteractionSource');
113
+ const SERIALIZED_INTERACTION_VALUE = Symbol('serializedInteractionValue');
114
+
115
+ // Render snapshots are pooled and rebuilt every frame; the interaction config
116
+ // only changes when a new object is attached, so cache the serialized form per
117
+ // car keyed on the interaction reference.
118
+ export function serializeParticipantInteractionCached(car) {
119
+ if (car[SERIALIZED_INTERACTION_SOURCE] !== car.interaction || !car[SERIALIZED_INTERACTION_VALUE]) {
120
+ car[SERIALIZED_INTERACTION_SOURCE] = car.interaction;
121
+ car[SERIALIZED_INTERACTION_VALUE] = serializeParticipantInteraction(car.interaction);
122
+ }
123
+ return car[SERIALIZED_INTERACTION_VALUE];
115
124
  }
116
125
 
117
126
  function resolveProfileName(profile) {
@@ -1,7 +1,7 @@
1
1
  import { decideDriverControls } from '../driverController.js';
2
2
  import { clamp } from '../simMath.js';
3
3
  import { updateReplayGhosts } from '../replay/replayGhosts.js';
4
- import { integrateVehiclePhysics } from '../vehicle/vehiclePhysics.js';
4
+ import { integrateVehiclePhysics, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
5
5
  import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
6
6
  import { applyRedFlagHoldForSimulation } from './redFlag.js';
7
7
  import { freezeRetiredCar, updateStalledDnfForSimulation } from './stalledDnf.js';
@@ -38,19 +38,28 @@ export function runRaceStep(simulation, dt) {
38
38
 
39
39
  const orderedCars = simulation.orderedCars();
40
40
  const raceContext = simulation.driverRaceContext(orderedCars);
41
- const orderedIndexById = new Map(orderedCars.map((car, index) => [car.id, index]));
42
- const driveCars = orderedCars.length === simulation.cars.length
43
- ? orderedCars
44
- : [
45
- ...orderedCars,
46
- ...simulation.cars.filter((car) => !orderedIndexById.has(car.id)),
47
- ];
41
+ for (let index = 0; index < simulation.cars.length; index += 1) {
42
+ simulation.cars[index]._driveOrderIndex = -1;
43
+ }
44
+ for (let index = 0; index < orderedCars.length; index += 1) {
45
+ orderedCars[index]._driveOrderIndex = index;
46
+ }
47
+ let driveCars = orderedCars;
48
+ if (orderedCars.length !== simulation.cars.length) {
49
+ driveCars = simulation._driveCarsScratch ??= [];
50
+ driveCars.length = 0;
51
+ driveCars.push(...orderedCars);
52
+ for (let index = 0; index < simulation.cars.length; index += 1) {
53
+ const car = simulation.cars[index];
54
+ if (car._driveOrderIndex === -1) driveCars.push(car);
55
+ }
56
+ }
48
57
  driveCars.forEach((car) => {
49
58
  if (car.destroyed || car.outOfRace) {
50
59
  freezeRetiredCar(car, { physicsMode: simulation.physicsMode });
51
60
  return;
52
61
  }
53
- const orderIndex = orderedIndexById.get(car.id);
62
+ const orderIndex = car._driveOrderIndex >= 0 ? car._driveOrderIndex : undefined;
54
63
  car.previousX = car.x;
55
64
  car.previousY = car.y;
56
65
  car.previousHeading = car.heading;
@@ -66,7 +75,7 @@ export function runRaceStep(simulation, dt) {
66
75
  throttle: controls.throttle ?? 0,
67
76
  brake: controls.brake ?? 0,
68
77
  };
69
- if (simulation.physicsMode === 'advanced') {
78
+ if (isSimulatorPhysicsMode(simulation.physicsMode)) {
70
79
  measureRuntimePhase(simulation, 'prePhysicsWheelSurface', () => {
71
80
  applyWheelSurfaceState(car, simulation.track);
72
81
  });
@@ -1,6 +1,7 @@
1
1
  import { pointAt } from '../track/trackModel.js';
2
2
  import { kphToSimSpeed } from '../units.js';
3
3
  import { freezeVehicleMotion, syncVehicleVelocityToHeading } from '../vehicle/vehicleKinematics.js';
4
+ import { isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
4
5
 
5
6
  export function setSafetyCarState(sim, deployed) {
6
7
  const next = Boolean(deployed);
@@ -82,7 +83,7 @@ export function setRedFlagState(sim, deployed) {
82
83
  });
83
84
  } else if (!car.finished) {
84
85
  car.speed = Math.max(car.speed, kphToSimSpeed(60));
85
- if (sim.physicsMode === 'advanced') syncVehicleVelocityToHeading(car);
86
+ if (isSimulatorPhysicsMode(sim.physicsMode)) syncVehicleVelocityToHeading(car);
86
87
  car.brake = 0;
87
88
  car.throttle = Math.max(car.throttle ?? 0, 0.35);
88
89
  }
@@ -20,7 +20,7 @@ export function calculatePitLaneSpeedingReview({ car, rule, stewardState }) {
20
20
 
21
21
  if (!isViolation) {
22
22
  return {
23
- nextState: { ...current, active: false },
23
+ nextState: current.active === false ? current : { ...current, active: false },
24
24
  event: null,
25
25
  penalty: null,
26
26
  };
@@ -129,7 +129,9 @@ export function reviewTrackLimitsForSimulation(sim) {
129
129
  sim.cars.forEach((car) => {
130
130
  if (isLegallyInsidePitLaneForTrackLimits(car)) {
131
131
  const current = sim.stewardState.trackLimits[car.id];
132
- sim.stewardState.trackLimits[car.id] = { ...(current ?? { violations: 0 }), active: false };
132
+ if (!current || current.active !== false) {
133
+ sim.stewardState.trackLimits[car.id] = { ...(current ?? { violations: 0 }), active: false };
134
+ }
133
135
  return;
134
136
  }
135
137
 
@@ -146,18 +148,26 @@ export function reviewTrackLimitsForSimulation(sim) {
146
148
  });
147
149
  }
148
150
 
151
+ const PIT_SPEED_REVIEW_SCRATCH = {
152
+ car: { id: null, speedKph: 0, trackState: null, pitLanePart: null },
153
+ rule: null,
154
+ stewardState: null,
155
+ };
156
+
149
157
  export function reviewPitLaneSpeedingForSimulation(sim) {
150
158
  const rule = getPenaltyRule(sim.rules, 'pitLaneSpeeding');
151
159
  sim.cars.forEach((car) => {
152
160
  const currentState = sim.stewardState.pitLaneSpeeding[car.id];
153
- const review = calculatePitLaneSpeedingReview({
154
- car: {
155
- ...car,
156
- speedKph: simSpeedToKph(car.speed),
157
- },
158
- rule,
159
- stewardState: currentState,
160
- });
161
+ // The steward only reads the speed and pit-lane location, so feed it a
162
+ // pooled view instead of spreading the whole car every step.
163
+ const reviewCar = PIT_SPEED_REVIEW_SCRATCH.car;
164
+ reviewCar.id = car.id;
165
+ reviewCar.speedKph = simSpeedToKph(car.speed);
166
+ reviewCar.trackState = car.trackState;
167
+ reviewCar.pitLanePart = car.pitLanePart;
168
+ PIT_SPEED_REVIEW_SCRATCH.rule = rule;
169
+ PIT_SPEED_REVIEW_SCRATCH.stewardState = currentState;
170
+ const review = calculatePitLaneSpeedingReview(PIT_SPEED_REVIEW_SCRATCH);
161
171
  sim.stewardState.pitLaneSpeeding[car.id] = review.nextState;
162
172
  if (review.event) sim.events.unshift({ ...review.event, at: sim.time });
163
173
  if (review.penalty) sim.recordPenalty(review.penalty);
@@ -22,7 +22,7 @@ export function calculateTrackLimitReview({ car, rule, track, stewardState }) {
22
22
  const wheelStates = wheelSurfaceState.wheels;
23
23
  if (wheelStates.some((wheelState) => wheelState.inPitLane)) {
24
24
  return {
25
- nextState: { ...current, active: false },
25
+ nextState: current.active === false ? current : { ...current, active: false },
26
26
  event: null,
27
27
  penalty: null,
28
28
  };
@@ -34,7 +34,7 @@ export function calculateTrackLimitReview({ car, rule, track, stewardState }) {
34
34
 
35
35
  if (!isViolation) {
36
36
  return {
37
- nextState: { ...current, active: false },
37
+ nextState: current.active === false ? current : { ...current, active: false },
38
38
  event: null,
39
39
  penalty: null,
40
40
  };
@@ -5,7 +5,7 @@ const OVERALL_BEST_1 = '_sectorPerformanceOverallBest1';
5
5
  const OVERALL_BEST_2 = '_sectorPerformanceOverallBest2';
6
6
 
7
7
  export function createEmptySectorTimes() {
8
- return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
8
+ return new Array(TELEMETRY_SECTOR_COUNT).fill(null);
9
9
  }
10
10
 
11
11
  export function createEmptySectorPerformance() {
@@ -17,11 +17,19 @@ export function createEmptySectorPerformance() {
17
17
  }
18
18
 
19
19
  export function serializeSectorTimes(values) {
20
- return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
20
+ const result = new Array(TELEMETRY_SECTOR_COUNT);
21
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
22
+ result[index] = finiteOrNull(values?.[index]);
23
+ }
24
+ return result;
21
25
  }
22
26
 
23
27
  export function serializeSectorPerformance(values) {
24
- return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
28
+ const result = new Array(TELEMETRY_SECTOR_COUNT);
29
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
30
+ result[index] = values?.[index] ?? null;
31
+ }
32
+ return result;
25
33
  }
26
34
 
27
35
  export function updateSectorPerformance(cars, stats = null) {
@@ -6,27 +6,59 @@ import { queryNearestTrackProjection, queryNearestTrackProjectionInto } from './
6
6
  import { recordStat } from './trackQueryStats.js';
7
7
 
8
8
  export function pointAt(track, distanceAlong) {
9
+ return pointAtInto(track, distanceAlong, {});
10
+ }
11
+
12
+ export function pointAtInto(track, distanceAlong, target) {
9
13
  const wrapped = wrapDistance(distanceAlong, track.length);
10
- const low = sampleIndexAtDistance(track, wrapped);
14
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
11
15
 
12
16
  const next = track.samples[low] ?? track.samples[0];
13
17
  const previous = track.samples[Math.max(0, low - 1)] ?? next;
14
18
  const span = Math.max(1, next.distance - previous.distance);
15
19
  const amount = clamp((wrapped - previous.distance) / span, 0, 1);
16
20
 
17
- return {
18
- x: previous.x + (next.x - previous.x) * amount,
19
- y: previous.y + (next.y - previous.y) * amount,
20
- heading: previous.heading + normalizeAngle(next.heading - previous.heading) * amount,
21
- normalX: previous.normalX + (next.normalX - previous.normalX) * amount,
22
- normalY: previous.normalY + (next.normalY - previous.normalY) * amount,
23
- curvature: previous.curvature + (next.curvature - previous.curvature) * amount,
24
- distance: wrapped,
25
- };
21
+ target.x = previous.x + (next.x - previous.x) * amount;
22
+ target.y = previous.y + (next.y - previous.y) * amount;
23
+ target.heading = previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
24
+ target.normalX = previous.normalX + (next.normalX - previous.normalX) * amount;
25
+ target.normalY = previous.normalY + (next.normalY - previous.normalY) * amount;
26
+ target.curvature = previous.curvature + (next.curvature - previous.curvature) * amount;
27
+ target.distance = wrapped;
28
+ return target;
26
29
  }
27
30
 
28
- export function sampleIndexAtDistance(track, distanceAlong) {
31
+ export function sampleHeadingCurvatureAtInto(track, distanceAlong, target) {
29
32
  const wrapped = wrapDistance(distanceAlong, track.length);
33
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
34
+
35
+ const next = track.samples[low] ?? track.samples[0];
36
+ const previous = track.samples[Math.max(0, low - 1)] ?? next;
37
+ const span = Math.max(1, next.distance - previous.distance);
38
+ const amount = clamp((wrapped - previous.distance) / span, 0, 1);
39
+
40
+ target.heading = previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
41
+ target.curvature = previous.curvature + (next.curvature - previous.curvature) * amount;
42
+ return target;
43
+ }
44
+
45
+ export function sampleHeadingAt(track, distanceAlong) {
46
+ const wrapped = wrapDistance(distanceAlong, track.length);
47
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
48
+
49
+ const next = track.samples[low] ?? track.samples[0];
50
+ const previous = track.samples[Math.max(0, low - 1)] ?? next;
51
+ const span = Math.max(1, next.distance - previous.distance);
52
+ const amount = clamp((wrapped - previous.distance) / span, 0, 1);
53
+
54
+ return previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
55
+ }
56
+
57
+ export function sampleIndexAtDistance(track, distanceAlong) {
58
+ return binarySampleIndexAtDistance(track, wrapDistance(distanceAlong, track.length));
59
+ }
60
+
61
+ function binarySampleIndexAtDistance(track, wrapped) {
30
62
  let low = 0;
31
63
  let high = track.samples.length - 1;
32
64
 
@@ -39,6 +71,43 @@ export function sampleIndexAtDistance(track, distanceAlong) {
39
71
  return low;
40
72
  }
41
73
 
74
+ // Uniform-bin index over sample distances: table[bin] holds the smallest
75
+ // sample index whose distance reaches the bin start, so a lookup plus a short
76
+ // forward walk returns exactly what the binary search would, in O(1). Built
77
+ // eagerly when the track query index is attached; lookups on sample arrays
78
+ // without a prepared table fall back to the binary search.
79
+ const SAMPLE_INDEX_LUTS = new WeakMap();
80
+
81
+ export function prepareSampleIndexLut(track) {
82
+ const samples = track?.samples;
83
+ if (!Array.isArray(samples) || samples.length < 2 || !Number.isFinite(track.length) || track.length <= 0) return;
84
+ if (SAMPLE_INDEX_LUTS.has(samples)) return;
85
+ const lastIndex = samples.length - 1;
86
+ const bins = Math.max(1, lastIndex * 2);
87
+ const table = new Int32Array(bins);
88
+ const binSize = track.length / bins;
89
+ let index = 0;
90
+ for (let bin = 0; bin < bins; bin += 1) {
91
+ const start = bin * binSize;
92
+ while (index < lastIndex && samples[index].distance < start) index += 1;
93
+ table[bin] = index;
94
+ }
95
+ SAMPLE_INDEX_LUTS.set(samples, { bins, binSize, table });
96
+ }
97
+
98
+ function sampleIndexAtDistanceUnwrapped(track, wrapped) {
99
+ const samples = track.samples;
100
+ const lut = Number.isFinite(wrapped) ? SAMPLE_INDEX_LUTS.get(samples) : null;
101
+ if (!lut) return binarySampleIndexAtDistance(track, wrapped);
102
+ const lastIndex = samples.length - 1;
103
+ let bin = Math.floor(wrapped / lut.binSize);
104
+ if (bin < 0) bin = 0;
105
+ else if (bin >= lut.bins) bin = lut.bins - 1;
106
+ let index = lut.table[bin];
107
+ while (index < lastIndex && samples[index].distance < wrapped) index += 1;
108
+ return index;
109
+ }
110
+
42
111
  export function nearestSampleInRange(track, position, startIndex, endIndex) {
43
112
  const sampleCount = track.samples.length - 1;
44
113
  let best = null;