@inventure71/paddockjs 4.1.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 (38) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/INSTALL_AND_UPDATE.md +9 -2
  3. package/README.md +4 -2
  4. package/docs/architecture.md +2 -0
  5. package/docs/component_inventory.md +1 -1
  6. package/docs/getting-started.md +3 -0
  7. package/docs/index.md +4 -2
  8. package/docs/loading.md +141 -0
  9. package/docs/system_specs.md +4 -1
  10. package/docs/testing-reliability-guide.md +62 -0
  11. package/package.json +5 -7
  12. package/src/data/index.d.ts +64 -1
  13. package/src/environment/index.d.ts +75 -12
  14. package/src/index.d.ts +72 -6
  15. package/src/simulation/collisionGeometry.js +7 -9
  16. package/src/simulation/driver/driverController.js +2 -2
  17. package/src/simulation/driver/edgeRecovery.js +2 -2
  18. package/src/simulation/driver/racingControls.js +35 -25
  19. package/src/simulation/driver/racingLinePlan.js +6 -5
  20. package/src/simulation/driver/rejoinControls.js +3 -3
  21. package/src/simulation/driver/trafficScan.js +48 -22
  22. package/src/simulation/participants/participantInteractions.js +24 -15
  23. package/src/simulation/race/raceStep.js +19 -10
  24. package/src/simulation/race/safetyCar.js +2 -1
  25. package/src/simulation/rules/pitLaneSpeedingSteward.js +1 -1
  26. package/src/simulation/rules/rulesReview.js +19 -9
  27. package/src/simulation/rules/trackLimitsSteward.js +2 -2
  28. package/src/simulation/timing/sectorPerformance.js +11 -3
  29. package/src/simulation/track/spatialQueries.js +80 -11
  30. package/src/simulation/track/trackModel.js +10 -1
  31. package/src/simulation/vehicle/contactResolution.js +4 -4
  32. package/src/simulation/vehicle/vehicleGeometry.js +87 -49
  33. package/src/simulation/vehicle/vehiclePhysics.js +7 -2
  34. package/src/simulation/vehicle/vehicleSnapshots.js +5 -2
  35. package/src/simulation/vehicle/vehicleState.js +3 -2
  36. package/src/ui/raceCanvasTemplate.js +3 -2
  37. package/examples/train-basic-policy.mjs +0 -276
  38. package/examples/trainingData.mjs +0 -133
@@ -39,6 +39,69 @@ export interface PaddockProceduralTrackOptions {
39
39
  };
40
40
  }
41
41
 
42
+ export interface PaddockTrackPoint {
43
+ x: number;
44
+ y: number;
45
+ }
46
+
47
+ export interface PaddockDrsZone {
48
+ id: string;
49
+ startRatio: number;
50
+ endRatio: number;
51
+ }
52
+
53
+ export interface PaddockResolvedProceduralTrackOptions {
54
+ profile: PaddockProceduralTrackProfile;
55
+ length: {
56
+ min: number;
57
+ max: number;
58
+ };
59
+ startStraight: {
60
+ grid: number;
61
+ exit: number;
62
+ blend: number;
63
+ lockExtra: number;
64
+ };
65
+ pitLane: {
66
+ enabled: boolean;
67
+ };
68
+ shape: {
69
+ scale: number;
70
+ cornerDensity: number;
71
+ variation: number;
72
+ };
73
+ validation: {
74
+ minClearanceMultiplier: number;
75
+ minShapeVariation: number;
76
+ minNonAdjacentArcDistance: number;
77
+ maxLocalTurnRadians: number;
78
+ maxSampleHeadingDeltaRadians: number;
79
+ };
80
+ attempts: {
81
+ primary: number;
82
+ fallback: number;
83
+ };
84
+ cacheKey: string;
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ export interface PaddockProceduralTrack {
89
+ name: string;
90
+ width: number;
91
+ kerbWidth: number;
92
+ gravelWidth: number;
93
+ runoffWidth: number;
94
+ barrierWidth: number;
95
+ sampleCount: number;
96
+ seed?: number;
97
+ curveInterpolation?: string;
98
+ centerlineControls?: readonly PaddockTrackPoint[];
99
+ drsZones?: readonly PaddockDrsZone[] | null;
100
+ pitLane?: { enabled?: boolean } | null;
101
+ generationOptions?: PaddockResolvedProceduralTrackOptions;
102
+ [key: string]: unknown;
103
+ }
104
+
42
105
  export type PaddockStabilityState = 'stable' | 'understeer' | 'oversteer' | 'spin-risk' | 'destroyed';
43
106
  export type PaddockPitIntent = 0 | 1 | 2;
44
107
  export type PaddockScenarioPreset = 'cornering' | 'off-track-recovery' | 'overtaking-pack' | 'pit-entry';
@@ -674,8 +737,8 @@ export interface PaddockDriverControllerContext {
674
737
  controlledDrivers: string[];
675
738
  actionSpec: PaddockActionSpec;
676
739
  observationSpec: PaddockObservationSpec;
677
- result: PaddockEnvironmentResult | unknown;
678
- previousResult: PaddockEnvironmentResult | unknown | null;
740
+ result: PaddockEnvironmentResult;
741
+ previousResult: PaddockEnvironmentResult | null;
679
742
  observation: Record<string, PaddockDriverObservation>;
680
743
  orderedObservations: Array<{
681
744
  driverId: string;
@@ -685,7 +748,7 @@ export interface PaddockDriverControllerContext {
685
748
  }>;
686
749
  metrics: Record<string, PaddockEnvironmentDriverMetrics>;
687
750
  events: RaceEvent[];
688
- info: PaddockEnvironmentResult['info'] | unknown | null;
751
+ info: PaddockEnvironmentResult['info'] | null;
689
752
  previousActions: PaddockActionMap;
690
753
  orderedPreviousActions: Array<PaddockAction | null>;
691
754
  actions: PaddockActionMap | null;
@@ -721,14 +784,14 @@ export interface PaddockDriverController {
721
784
  }
722
785
 
723
786
  export interface PaddockControllerRuntime {
724
- reset(options?: unknown): PaddockEnvironmentResult | unknown;
725
- step(actions: PaddockActionMap): PaddockEnvironmentResult | unknown;
787
+ reset(options?: unknown): PaddockEnvironmentResult;
788
+ step(actions: PaddockActionMap): PaddockEnvironmentResult;
726
789
  resetDrivers?(
727
790
  placements: Record<string, PaddockScenarioPlacement>,
728
791
  resultOptions?: PaddockEnvironmentResultOptions
729
- ): PaddockEnvironmentResult | unknown;
792
+ ): PaddockEnvironmentResult;
730
793
  getObservation(): Record<string, PaddockDriverObservation>;
731
- getState?(options?: { output?: 'full' | 'minimal' | 'none' }): PaddockEnvironmentResult['state'] | unknown;
794
+ getState?(options?: { output?: 'full' | 'minimal' | 'none' }): PaddockEnvironmentResult['state'];
732
795
  getActionSpec(): PaddockActionSpec;
733
796
  getObservationSpec(): PaddockObservationSpec;
734
797
  destroy?(): void;
@@ -743,7 +806,7 @@ export interface PaddockDriverControllerLoopOptions {
743
806
  }
744
807
 
745
808
  export interface PaddockDriverControllerLoop {
746
- readonly result: PaddockEnvironmentResult | unknown | null;
809
+ readonly result: PaddockEnvironmentResult | null;
747
810
  readonly actionSpec: PaddockActionSpec | null;
748
811
  readonly observationSpec: PaddockObservationSpec | null;
749
812
  readonly stats: {
@@ -756,13 +819,13 @@ export interface PaddockDriverControllerLoop {
756
819
  running: boolean;
757
820
  lastError: unknown | null;
758
821
  };
759
- reset(options?: unknown): Promise<PaddockEnvironmentResult | unknown>;
822
+ reset(options?: unknown): Promise<PaddockEnvironmentResult>;
760
823
  resetDrivers(
761
824
  placements: Record<string, PaddockScenarioPlacement>,
762
825
  resultOptions?: PaddockEnvironmentResultOptions
763
- ): Promise<PaddockEnvironmentResult | unknown>;
764
- step(): Promise<PaddockEnvironmentResult | unknown>;
765
- stepFrame(): Promise<PaddockEnvironmentResult | unknown>;
826
+ ): Promise<PaddockEnvironmentResult>;
827
+ step(): Promise<PaddockEnvironmentResult>;
828
+ stepFrame(): Promise<PaddockEnvironmentResult>;
766
829
  start(): void;
767
830
  stop(): void;
768
831
  }
package/src/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  PaddockActionSpec,
3
3
  PaddockDriverControllerLoop,
4
4
  PaddockDriverControllerLoopOptions,
5
+ PaddockEnvironmentResult,
5
6
  PaddockEnvironmentOptions,
6
7
  PaddockObservationSpec,
7
8
  PaddockParticipantInteraction as EnvPaddockParticipantInteraction,
@@ -20,6 +21,8 @@ export type {
20
21
  PaddockDriverControllerContext,
21
22
  PaddockDriverControllerLoop,
22
23
  PaddockDriverControllerLoopOptions,
24
+ PaddockEnvironmentResult,
25
+ PaddockEnvironmentSnapshotResult,
23
26
  PaddockObservationSpec,
24
27
  } from './environment/index.js';
25
28
 
@@ -71,6 +74,69 @@ export interface PaddockProceduralTrackOptions {
71
74
  };
72
75
  }
73
76
 
77
+ export interface PaddockTrackPoint {
78
+ x: number;
79
+ y: number;
80
+ }
81
+
82
+ export interface PaddockDrsZone {
83
+ id: string;
84
+ startRatio: number;
85
+ endRatio: number;
86
+ }
87
+
88
+ export interface PaddockResolvedProceduralTrackOptions {
89
+ profile: PaddockProceduralTrackProfile;
90
+ length: {
91
+ min: number;
92
+ max: number;
93
+ };
94
+ startStraight: {
95
+ grid: number;
96
+ exit: number;
97
+ blend: number;
98
+ lockExtra: number;
99
+ };
100
+ pitLane: {
101
+ enabled: boolean;
102
+ };
103
+ shape: {
104
+ scale: number;
105
+ cornerDensity: number;
106
+ variation: number;
107
+ };
108
+ validation: {
109
+ minClearanceMultiplier: number;
110
+ minShapeVariation: number;
111
+ minNonAdjacentArcDistance: number;
112
+ maxLocalTurnRadians: number;
113
+ maxSampleHeadingDeltaRadians: number;
114
+ };
115
+ attempts: {
116
+ primary: number;
117
+ fallback: number;
118
+ };
119
+ cacheKey: string;
120
+ [key: string]: unknown;
121
+ }
122
+
123
+ export interface PaddockProceduralTrack {
124
+ name: string;
125
+ width: number;
126
+ kerbWidth: number;
127
+ gravelWidth: number;
128
+ runoffWidth: number;
129
+ barrierWidth: number;
130
+ sampleCount: number;
131
+ seed?: number;
132
+ curveInterpolation?: string;
133
+ centerlineControls?: readonly PaddockTrackPoint[];
134
+ drsZones?: readonly PaddockDrsZone[] | null;
135
+ pitLane?: { enabled?: boolean } | null;
136
+ generationOptions?: PaddockResolvedProceduralTrackOptions;
137
+ [key: string]: unknown;
138
+ }
139
+
74
140
  export type PaddockStabilityState = 'stable' | 'understeer' | 'oversteer' | 'spin-risk' | 'destroyed';
75
141
  export type PaddockPitIntent = 0 | 1 | 2;
76
142
  export type PaddockPitIntentRequest = PaddockPitIntent | {
@@ -959,14 +1025,14 @@ export interface PaddockEnvironmentResultOptions {
959
1025
  }
960
1026
 
961
1027
  export interface F1SimulatorExpertApi {
962
- reset(options?: unknown): unknown;
963
- step(actions: Record<string, F1SimulatorExpertAction>): unknown;
1028
+ reset(options?: unknown): PaddockEnvironmentResult;
1029
+ step(actions: Record<string, F1SimulatorExpertAction>): PaddockEnvironmentResult;
964
1030
  resetDrivers?(
965
1031
  placements: Record<string, PaddockScenarioPlacement>,
966
1032
  resultOptions?: PaddockEnvironmentResultOptions
967
- ): unknown;
968
- getObservation(): unknown;
969
- getState(): unknown;
1033
+ ): PaddockEnvironmentResult;
1034
+ getObservation(): PaddockEnvironmentResult['observation'];
1035
+ getState(): PaddockEnvironmentResult['state'];
970
1036
  getActionSpec(): PaddockActionSpec;
971
1037
  getObservationSpec(): PaddockObservationSpec;
972
1038
  attachExternalRenderer(source: PaddockExternalRendererSource): void;
@@ -1133,7 +1199,7 @@ export function simSpeedToMetersPerSecond(simSpeed: number): number;
1133
1199
 
1134
1200
  export function createPaddockSimulator(options: F1SimulatorOptions): PaddockSimulatorController;
1135
1201
  export function mountF1Simulator(root: Element, options: F1SimulatorOptions): Promise<F1MountedSimulator>;
1136
- export function createProceduralTrack(seed?: number | string, options?: PaddockProceduralTrackOptions): unknown;
1202
+ export function createProceduralTrack(seed?: number | string, options?: PaddockProceduralTrackOptions): PaddockProceduralTrack;
1137
1203
  export function createPaddockDriverControllerLoop(options: PaddockDriverControllerLoopOptions): PaddockDriverControllerLoop;
1138
1204
 
1139
1205
  export function mountRaceControls<T extends Element>(root: T, simulator: PaddockSimulatorController): T;
@@ -1,7 +1,4 @@
1
- import {
2
- getVehicleGeometryState,
3
- vehicleAxes,
4
- } from './vehicle/vehicleGeometry.js';
1
+ import { getVehicleGeometryState } from './vehicle/vehicleGeometry.js';
5
2
  import { normalizeAngle } from './simMath.js';
6
3
 
7
4
  const DEFAULT_SWEEP_STEPS = 16;
@@ -340,17 +337,18 @@ function writeInterpolatedBodyShape(target, geometryState, amount) {
340
337
  const x = pose.previousX + (pose.x - pose.previousX) * amount;
341
338
  const y = pose.previousY + (pose.y - pose.previousY) * amount;
342
339
  const heading = normalizeAngle(pose.previousHeading + headingDelta * amount);
343
- const axes = vehicleAxes(heading);
340
+ const cos = Math.cos(heading);
341
+ const sin = Math.sin(heading);
344
342
  const halfLength = target.halfLength;
345
343
  const halfWidth = target.halfWidth;
346
344
 
347
345
  target.center.x = x;
348
346
  target.center.y = y;
349
347
  target.heading = heading;
350
- target.forward.x = axes.forward.x;
351
- target.forward.y = axes.forward.y;
352
- target.right.x = axes.right.x;
353
- target.right.y = axes.right.y;
348
+ target.forward.x = cos;
349
+ target.forward.y = sin;
350
+ target.right.x = -sin;
351
+ target.right.y = cos;
354
352
 
355
353
  writeOffsetCorner(target.corners[0], target.center, target.forward, target.right, halfLength, halfWidth);
356
354
  writeOffsetCorner(target.corners[1], target.center, target.forward, target.right, halfLength, -halfWidth);
@@ -4,7 +4,7 @@ import { decideRacingControls } from './racingControls.js';
4
4
  import { decideRejoinControls } from './rejoinControls.js';
5
5
  import { shouldContinueRejoinRecovery } from './edgeRecovery.js';
6
6
  import { decideSafetyCarControls } from './safetyCarControls.js';
7
- import { VEHICLE_LIMITS } from '../vehicle/vehiclePhysics.js';
7
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
8
8
 
9
9
  export function decideDriverControls({ car, orderIndex, race }) {
10
10
  if (car.manualControls) return car.manualControls;
@@ -37,7 +37,7 @@ export function decideDriverControls({ car, orderIndex, race }) {
37
37
  }
38
38
 
39
39
  function shouldStabilizeSimulatorCar(car, race) {
40
- if (race.physicsMode !== 'advanced') return false;
40
+ if (!isSimulatorPhysicsMode(race.physicsMode)) return false;
41
41
  const gripUsage = car.gripUsage ?? 0;
42
42
  const slipAngle = Math.abs(car.slipAngleRadians ?? 0);
43
43
  const edgeDistance = race.track.width / 2 - VEHICLE_LIMITS.carWidth * 2.25;
@@ -1,11 +1,11 @@
1
1
  import { clamp, normalizeAngle } from '../simMath.js';
2
2
  import { metersToSimUnits } from '../units.js';
3
3
  import { pointAt } from '../track/trackModel.js';
4
- import { VEHICLE_LIMITS } from '../vehicle/vehiclePhysics.js';
4
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
5
5
  import { REJOIN_LOOKAHEAD_BASE, REJOIN_LOOKAHEAD_MAX } from './driverControlConstants.js';
6
6
 
7
7
  export function calculateTrackEdgeGuard(car, race) {
8
- const simulatorMode = race.physicsMode === 'advanced';
8
+ const simulatorMode = isSimulatorPhysicsMode(race.physicsMode);
9
9
  const trackLimit = race.track.width / 2;
10
10
  const halfCarWidth = VEHICLE_LIMITS.carWidth / 2;
11
11
  const wholeCarOutsideLimit = trackLimit + halfCarWidth;
@@ -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) {