@inventure71/paddockjs 2.0.0 → 2.0.1

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 (62) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/INSTALL_AND_UPDATE.md +1 -1
  3. package/README.md +30 -5
  4. package/docs/architecture.md +15 -9
  5. package/docs/concepts.md +6 -4
  6. package/docs/data_contract.md +28 -15
  7. package/docs/data_model.md +1 -1
  8. package/docs/index.md +9 -0
  9. package/docs/rules.md +37 -7
  10. package/docs/sense_contract.md +59 -0
  11. package/docs/system_specs.md +56 -7
  12. package/docs/training.md +16 -10
  13. package/package.json +1 -1
  14. package/src/api/PaddockSimulatorController.js +2 -33
  15. package/src/app/BrowserExpertAdapter.js +9 -300
  16. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  17. package/src/app/F1SimulatorApp.js +1 -42
  18. package/src/config/restartOptions.js +34 -0
  19. package/src/environment/controllerLoop.js +17 -2
  20. package/src/environment/episode.js +14 -2
  21. package/src/environment/index.d.ts +16 -2
  22. package/src/environment/observationVector.js +200 -0
  23. package/src/environment/observations.js +58 -285
  24. package/src/environment/recorder.js +13 -6
  25. package/src/environment/rewards.js +13 -2
  26. package/src/environment/runtime.js +62 -14
  27. package/src/environment/scenarios.js +22 -3
  28. package/src/environment/sensors/index.js +3 -3
  29. package/src/environment/sensors/opponentRadar.js +13 -3
  30. package/src/environment/sensors/sensorTargets.js +8 -2
  31. package/src/environment/sensors/surfaceRays.js +90 -26
  32. package/src/environment/sensors/trackRays.js +185 -22
  33. package/src/environment/specs.js +78 -40
  34. package/src/index.d.ts +2 -0
  35. package/src/index.js +1 -32
  36. package/src/simulation/pit/pitFlow.js +5 -0
  37. package/src/simulation/pit/pitKinematics.js +24 -0
  38. package/src/simulation/pit/pitQueue.js +5 -0
  39. package/src/simulation/pit/pitRouteMovement.js +7 -0
  40. package/src/simulation/pit/pitService.js +2 -0
  41. package/src/simulation/pit/pitTireService.js +2 -0
  42. package/src/simulation/race/raceLifecycle.js +2 -6
  43. package/src/simulation/race/raceProgress.js +6 -1
  44. package/src/simulation/race/raceStep.js +1 -1
  45. package/src/simulation/race/redFlag.js +3 -3
  46. package/src/simulation/race/safetyCar.js +10 -4
  47. package/src/simulation/race/stalledDnf.js +8 -17
  48. package/src/simulation/raceSimulation.js +1 -0
  49. package/src/simulation/rules/rulesReview.js +3 -0
  50. package/src/simulation/snapshots/raceSnapshots.js +1 -0
  51. package/src/simulation/track/trackQueryGrid.js +94 -0
  52. package/src/simulation/track/trackQueryIndex.js +16 -302
  53. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  54. package/src/simulation/track/trackQueryScratch.js +46 -0
  55. package/src/simulation/track/trackQueryStats.js +28 -0
  56. package/src/simulation/vehicle/contactResolution.js +67 -2
  57. package/src/simulation/vehicle/runoffResponse.js +11 -12
  58. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  59. package/src/simulation/vehicle/vehiclePhysics.js +2 -1
  60. package/src/simulation/vehicle/vehicleSnapshots.js +11 -0
  61. package/src/simulation/vehicle/vehicleState.js +36 -2
  62. package/src/simulation/vehicle/wheelSurface.js +3 -1
@@ -1,7 +1,8 @@
1
1
  import { normalizeLookaheadMeters } from './observationOptions.js';
2
+ import { effectiveSensorOptions } from './observations.js';
2
3
  import { normalizeRayOptions } from './sensors.js';
3
4
 
4
- const PHYSICAL_RAY_SURFACE_CHANNELS = Object.freeze(['kerb', 'illegalSurface']);
5
+ const MODEL_RAY_SURFACE_CHANNELS = Object.freeze(['kerb', 'illegalSurface']);
5
6
 
6
7
  export function buildActionSpec(options) {
7
8
  const compounds = Array.isArray(options.rules?.modules?.tireStrategy?.compounds) &&
@@ -32,7 +33,7 @@ export function buildObservationSpec(options) {
32
33
  : normalizeLookaheadMeters(options.observation?.lookaheadMeters);
33
34
  const profile = options.observation?.profile ?? 'default';
34
35
  return {
35
- version: profile === 'physical-driver' ? 4 : 2,
36
+ version: profile === 'physical-driver' ? 6 : 3,
36
37
  controlledDrivers: [...options.controlledDrivers],
37
38
  object: {
38
39
  profile,
@@ -45,12 +46,15 @@ export function buildObservationSpec(options) {
45
46
  { name: 'steeringAngleRadians', unit: 'rad' },
46
47
  { name: 'throttle', unit: 'normalized' },
47
48
  { name: 'brake', unit: 'normalized' },
49
+ { name: 'appliedControls', unit: 'nullable:normalized-controls' },
48
50
  { name: 'lateralG', unit: 'g' },
49
51
  { name: 'longitudinalG', unit: 'g' },
50
52
  { name: 'gripUsage', unit: 'ratio' },
51
53
  { name: 'slipAngleRadians', unit: 'rad' },
52
54
  { name: 'tractionLimited', unit: 'boolean' },
53
55
  { name: 'stabilityState', unit: 'label' },
56
+ { name: 'destroyed', unit: 'boolean' },
57
+ { name: 'destroyReason', unit: 'nullable:label' },
54
58
  { name: 'lap', unit: 'count' },
55
59
  { name: 'completedLaps', unit: 'count' },
56
60
  { name: 'lapProgressMeters', unit: 'm' },
@@ -99,40 +103,8 @@ export function buildObservationSpec(options) {
99
103
  { name: 'redFlag', unit: 'boolean' },
100
104
  { name: 'totalLaps', unit: 'count' },
101
105
  ],
102
- rays: {
103
- enabled: Boolean(rayOptions.enabled),
104
- anglesDegrees: [...rayOptions.anglesDegrees],
105
- lengthMeters: rayOptions.lengthMeters,
106
- defaultLengthMeters: rayOptions.defaultLengthMeters,
107
- rays: rayOptions.rays.map((ray) => ({ ...ray })),
108
- channels: [...rayOptions.channels],
109
- precision: rayOptions.precision,
110
- track: {
111
- distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
112
- hit: { unit: 'boolean' },
113
- kind: { values: ['exit', 'entry', null] },
114
- },
115
- roadEdge: {
116
- distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
117
- hit: { unit: 'boolean' },
118
- kind: { values: ['exit', 'entry', null] },
119
- },
120
- kerb: surfaceRaySpec(rayOptions.lengthMeters),
121
- illegalSurface: surfaceRaySpec(rayOptions.lengthMeters),
122
- car: {
123
- distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
124
- hit: { unit: 'boolean' },
125
- driverId: { nullable: true },
126
- targetId: { nullable: true },
127
- targetType: { values: ['car', 'replayGhost', null] },
128
- relativeSpeedKph: { unit: 'kph' },
129
- },
130
- },
131
- nearbyCars: {
132
- enabled: Boolean(nearbyOptions.enabled),
133
- maxCars: nearbyOptions.maxCars,
134
- radiusMeters: nearbyOptions.radiusMeters,
135
- },
106
+ rays: buildRayObjectSpec(rayOptions),
107
+ nearbyCars: buildNearbyObjectSpec(nearbyOptions),
136
108
  track: {
137
109
  lengthMeters: { unit: 'm' },
138
110
  widthMeters: { unit: 'm' },
@@ -149,6 +121,69 @@ export function buildObservationSpec(options) {
149
121
  vector: {
150
122
  schema: buildVectorSchema({ rayOptions, nearbyOptions, lookaheadMeters, profile }),
151
123
  },
124
+ perDriver: buildPerDriverObservationSpecs(options, { rayOptions, nearbyOptions, lookaheadMeters, profile }),
125
+ };
126
+ }
127
+
128
+ function buildPerDriverObservationSpecs(options, { rayOptions, nearbyOptions, lookaheadMeters, profile }) {
129
+ if (Object.keys(options.sensorsByDriver ?? {}).length === 0) return {};
130
+ const defaultSensors = { rays: rayOptions, nearbyCars: nearbyOptions };
131
+ return Object.fromEntries(options.controlledDrivers.map((driverId) => {
132
+ const sensors = effectiveSensorOptions(options, driverId, defaultSensors, true);
133
+ return [driverId, {
134
+ object: {
135
+ rays: buildRayObjectSpec(sensors.rays),
136
+ nearbyCars: buildNearbyObjectSpec(sensors.nearbyCars),
137
+ },
138
+ vector: {
139
+ schema: buildVectorSchema({
140
+ rayOptions: sensors.rays,
141
+ nearbyOptions: sensors.nearbyCars,
142
+ lookaheadMeters,
143
+ profile,
144
+ }),
145
+ },
146
+ }];
147
+ }));
148
+ }
149
+
150
+ function buildRayObjectSpec(rayOptions) {
151
+ return {
152
+ enabled: Boolean(rayOptions.enabled),
153
+ anglesDegrees: [...rayOptions.anglesDegrees],
154
+ lengthMeters: rayOptions.lengthMeters,
155
+ defaultLengthMeters: rayOptions.defaultLengthMeters,
156
+ rays: rayOptions.rays.map((ray) => ({ ...ray })),
157
+ channels: [...rayOptions.channels],
158
+ precision: rayOptions.precision,
159
+ track: {
160
+ distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
161
+ hit: { unit: 'boolean' },
162
+ kind: { values: ['exit', 'entry', null] },
163
+ },
164
+ roadEdge: {
165
+ distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
166
+ hit: { unit: 'boolean' },
167
+ kind: { values: ['exit', 'entry', null] },
168
+ },
169
+ kerb: surfaceRaySpec(rayOptions.lengthMeters),
170
+ illegalSurface: surfaceRaySpec(rayOptions.lengthMeters),
171
+ car: {
172
+ distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
173
+ hit: { unit: 'boolean' },
174
+ driverId: { nullable: true },
175
+ targetId: { nullable: true },
176
+ targetType: { values: ['car', 'replayGhost', null] },
177
+ relativeSpeedKph: { unit: 'kph' },
178
+ },
179
+ };
180
+ }
181
+
182
+ function buildNearbyObjectSpec(nearbyOptions) {
183
+ return {
184
+ enabled: Boolean(nearbyOptions.enabled),
185
+ maxCars: nearbyOptions.maxCars,
186
+ radiusMeters: nearbyOptions.radiusMeters,
152
187
  };
153
188
  }
154
189
 
@@ -193,6 +228,7 @@ function buildVectorSchema({ rayOptions, nearbyOptions, lookaheadMeters, profile
193
228
  { name: `contactPatches[${index}].surfaceCode`, scale: 'surface-code' },
194
229
  { name: `contactPatches[${index}].onLegalSurface`, scale: 'boolean' },
195
230
  { name: `contactPatches[${index}].signedOffsetMeters`, unit: 'm', scale: 'fixed:meters' },
231
+ { name: `contactPatches[${index}].crossTrackErrorMeters`, unit: 'm', scale: 'fixed:meters' },
196
232
  );
197
233
  }
198
234
  }
@@ -212,15 +248,16 @@ function buildVectorSchema({ rayOptions, nearbyOptions, lookaheadMeters, profile
212
248
  { name: `rays[${index}].car.distanceRatio`, scale: '0..1' },
213
249
  { name: `rays[${index}].car.hit`, scale: 'boolean' },
214
250
  { name: `rays[${index}].car.relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
251
+ { name: `rays[${index}].car.targetTypeReplayGhost`, scale: 'boolean' },
215
252
  );
216
- if (includePhysicalDriverSenses) {
217
- PHYSICAL_RAY_SURFACE_CHANNELS.forEach((channel) => {
253
+ MODEL_RAY_SURFACE_CHANNELS.forEach((channel) => {
254
+ if (rayOptions.channels?.includes?.(channel)) {
218
255
  schema.push(
219
256
  { name: `rays[${index}].${channel}.distanceRatio`, scale: '0..1' },
220
257
  { name: `rays[${index}].${channel}.hit`, scale: 'boolean' },
221
258
  );
222
- });
223
- }
259
+ }
260
+ });
224
261
  });
225
262
  }
226
263
  if (nearbyOptions.enabled) {
@@ -234,6 +271,7 @@ function buildVectorSchema({ rayOptions, nearbyOptions, lookaheadMeters, profile
234
271
  { name: `nearbyCars[${index}].relativeHeadingRadians`, unit: 'rad', scale: 'fixed:pi' },
235
272
  { name: `nearbyCars[${index}].ahead`, scale: 'boolean' },
236
273
  { name: `nearbyCars[${index}].sameLap`, scale: 'boolean' },
274
+ { name: `nearbyCars[${index}].entityTypeReplayGhost`, scale: 'boolean' },
237
275
  );
238
276
  if (includePhysicalDriverSenses) {
239
277
  schema.push(
package/src/index.d.ts CHANGED
@@ -648,6 +648,8 @@ export interface CarSnapshot {
648
648
  tire: TireCompound;
649
649
  interaction?: PaddockParticipantInteraction;
650
650
  lap: number;
651
+ velocityX?: number | null;
652
+ velocityY?: number | null;
651
653
  speedKph: number;
652
654
  finishRank?: number | null;
653
655
  status?: 'racing' | 'waved-flag' | 'destroyed' | string;
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import './styles.css';
2
2
  import { F1SimulatorApp } from './app/F1SimulatorApp.js';
3
3
  import { resolveF1SimulatorOptions } from './config/defaultOptions.js';
4
+ import { mergeRestartOptions } from './config/restartOptions.js';
4
5
  import { createF1SimulatorShell } from './ui/shellTemplate.js';
5
6
  export {
6
7
  createPaddockSimulator,
@@ -51,38 +52,6 @@ function assertMountRoot(root) {
51
52
  }
52
53
  }
53
54
 
54
- function mergeRestartOptions(previousOptions, nextOptions) {
55
- const resetFromPreset = Object.hasOwn(nextOptions, 'preset');
56
- const previousUi = resetFromPreset ? {} : previousOptions.ui;
57
- const previousTheme = resetFromPreset ? {} : previousOptions.theme;
58
- return {
59
- ...previousOptions,
60
- ...nextOptions,
61
- ui: {
62
- ...previousUi,
63
- ...(nextOptions.ui ?? {}),
64
- raceDataBanners: {
65
- ...(previousUi.raceDataBanners ?? {}),
66
- ...(nextOptions.ui?.raceDataBanners ?? {}),
67
- },
68
- },
69
- theme: {
70
- ...previousTheme,
71
- ...(nextOptions.theme ?? {}),
72
- },
73
- assets: {
74
- ...previousOptions.assets,
75
- ...(nextOptions.assets ?? {}),
76
- trackTextures: {
77
- ...previousOptions.assets.trackTextures,
78
- ...(nextOptions.assets?.trackTextures ?? {}),
79
- },
80
- },
81
- drivers: nextOptions.drivers ?? previousOptions.drivers,
82
- entries: nextOptions.entries ?? previousOptions.entries,
83
- };
84
- }
85
-
86
55
  export async function mountF1Simulator(root, options = {}) {
87
56
  assertMountRoot(root);
88
57
 
@@ -17,6 +17,7 @@ import {
17
17
  isPitServiceQueueOccupied,
18
18
  releasePitQueue,
19
19
  } from './pitService.js';
20
+ import { syncPitCarKinematics } from './pitKinematics.js';
20
21
  import {
21
22
  createPitApproachPoints,
22
23
  createRoute,
@@ -209,6 +210,10 @@ export function advancePitStopCar(sim, car, delta) {
209
210
  car.speed = 0;
210
211
  car.throttle = 0;
211
212
  car.brake = 1;
213
+ car.steeringAngle = 0;
214
+ car.yawRate = 0;
215
+ car.turnRadius = Infinity;
216
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
212
217
  applyWheelSurfaceState(car, sim.track);
213
218
  car.progress = car.trackState.distance;
214
219
  car.raceDistance = getPitBoxRaceDistance(sim, stop, {
@@ -0,0 +1,24 @@
1
+ export function syncPitCarKinematics(car, { physicsMode = 'simulator' } = {}) {
2
+ const speed = Number.isFinite(car.speed) ? car.speed : 0;
3
+ const heading = Number.isFinite(car.heading) ? car.heading : 0;
4
+ if (physicsMode === 'simulator') {
5
+ car.velocityX = Math.cos(heading) * speed;
6
+ car.velocityY = Math.sin(heading) * speed;
7
+ } else {
8
+ car.velocityX = null;
9
+ car.velocityY = null;
10
+ }
11
+ car.appliedControls = {
12
+ steering: car.steeringAngle ?? 0,
13
+ throttle: car.throttle ?? 0,
14
+ brake: car.brake ?? 0,
15
+ };
16
+ car.lateralAcceleration = 0;
17
+ car.longitudinalAcceleration = 0;
18
+ car.lateralG = 0;
19
+ car.longitudinalG = 0;
20
+ car.gripUsage = 0;
21
+ car.slipAngleRadians = 0;
22
+ car.tractionLimited = false;
23
+ car.stabilityState = 'stable';
24
+ }
@@ -1,5 +1,6 @@
1
1
  import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
2
2
  import { createRoute, routePoint } from './pitRouting.js';
3
+ import { syncPitCarKinematics } from './pitKinematics.js';
3
4
  import { PIT_QUEUE_RELEASE_SPEED } from './pitServiceConstants.js';
4
5
  import { getPitBoxRaceDistance } from './pitOccupancy.js';
5
6
 
@@ -17,6 +18,10 @@ export function beginPitQueue(sim, car, box) {
17
18
  car.speed = 0;
18
19
  car.throttle = 0;
19
20
  car.brake = 1;
21
+ car.steeringAngle = 0;
22
+ car.yawRate = 0;
23
+ car.turnRadius = Infinity;
24
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
20
25
  applyWheelSurfaceState(car, sim.track);
21
26
  car.progress = car.trackState.distance;
22
27
  car.raceDistance = getPitBoxRaceDistance(sim, stop, {
@@ -8,6 +8,7 @@ import {
8
8
  routeLimiterActiveAt,
9
9
  sampleRoute,
10
10
  } from './pitRouting.js';
11
+ import { syncPitCarKinematics } from './pitKinematics.js';
11
12
  import { completePitPenaltyService } from './pitPenaltyService.js';
12
13
  import { completePitService } from './pitTireService.js';
13
14
  import { getPitBoxRaceDistance, getPitStopBox } from './pitOccupancy.js';
@@ -51,6 +52,7 @@ export function applyPitRoutePosition(sim, car, delta) {
51
52
  VEHICLE_LIMITS.maxSteer,
52
53
  );
53
54
  car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
55
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
54
56
  stop.routeProgress = nextProgress;
55
57
  const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
56
58
  car.raceDistance = stop.routeStartRaceDistance +
@@ -111,6 +113,7 @@ export function applyPitRoutePosition(sim, car, delta) {
111
113
  VEHICLE_LIMITS.maxSteer,
112
114
  );
113
115
  car.turnRadius = Math.abs(car.yawRate) < 0.001 ? Infinity : car.speed / Math.abs(car.yawRate);
116
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
114
117
  applyWheelSurfaceState(car, sim.track);
115
118
  stop.routeProgress = nextProgress;
116
119
  const routeAmount = route.length > 0 ? stop.routeProgress / route.length : 1;
@@ -148,6 +151,10 @@ export function advancePitService(sim, car, delta) {
148
151
  car.speed = 0;
149
152
  car.throttle = 0;
150
153
  car.brake = 1;
154
+ car.steeringAngle = 0;
155
+ car.yawRate = 0;
156
+ car.turnRadius = Infinity;
157
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
151
158
  applyWheelSurfaceState(car, sim.track);
152
159
  car.progress = car.trackState.distance;
153
160
  car.raceDistance = getPitBoxRaceDistance(sim, stop, box);
@@ -2,6 +2,7 @@ import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
2
2
  import { normalizePitCrewStats } from './pitState.js';
3
3
  import { beginPitPenaltyService } from './pitPenaltyService.js';
4
4
  import { beginTireService } from './pitTireService.js';
5
+ import { syncPitCarKinematics } from './pitKinematics.js';
5
6
  import { getPitBoxRaceDistance } from './pitOccupancy.js';
6
7
 
7
8
  export { normalizePitCrewStats };
@@ -32,6 +33,7 @@ export function beginPitService(sim, car, box) {
32
33
  car.steeringAngle = 0;
33
34
  car.yawRate = 0;
34
35
  car.turnRadius = Infinity;
36
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
35
37
  applyWheelSurfaceState(car, sim.track);
36
38
  car.progress = car.trackState.distance;
37
39
  car.raceDistance = getPitBoxRaceDistance(sim, stop, box);
@@ -6,6 +6,7 @@ import {
6
6
  pitMainLanePointAt,
7
7
  routePoint,
8
8
  } from './pitRouting.js';
9
+ import { syncPitCarKinematics } from './pitKinematics.js';
9
10
  import { getPitBoxRaceDistance, getPitStopBox } from './pitOccupancy.js';
10
11
  import { calculatePitServiceProfile } from './pitServiceProfile.js';
11
12
  import { PIT_BOX_APPROACH_DISTANCE, PIT_EXIT_RELEASE_SPEED_KPH } from './pitServiceConstants.js';
@@ -79,6 +80,7 @@ export function finishPitExit(sim, car) {
79
80
  car.speed = Math.max(car.speed, kphToSimSpeed(PIT_EXIT_RELEASE_SPEED_KPH));
80
81
  car.throttle = 0.55;
81
82
  car.brake = 0;
83
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
82
84
  applyWheelSurfaceState(car, sim.track);
83
85
  car.progress = car.trackState.distance;
84
86
  car.raceDistance = Math.max(car.raceDistance ?? 0, stop.routeEndRaceDistance ?? car.raceDistance ?? 0);
@@ -2,6 +2,7 @@ import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
2
2
  import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
3
3
  import { clamp } from '../simMath.js';
4
4
  import { nearestTrackStateForCar } from '../track/trackStatePolicy.js';
5
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
5
6
 
6
7
  export function setPitLaneOpenState(sim, open) {
7
8
  const next = Boolean(open);
@@ -58,12 +59,7 @@ export function holdGridCars(sim) {
58
59
  car.x = position.x;
59
60
  car.y = position.y;
60
61
  car.heading = gridPoint.heading;
61
- car.speed = 0;
62
- car.throttle = 0;
63
- car.brake = 1;
64
- car.steeringAngle = 0;
65
- car.yawRate = 0;
66
- car.turnRadius = Infinity;
62
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
67
63
  car.progress = gridPoint.distance;
68
64
  car.raceDistance = car.gridDistance;
69
65
  applyWheelSurfaceState(car, sim.track);
@@ -14,13 +14,18 @@ import {
14
14
  } from '../timing/raceTiming.js';
15
15
  import { progressDelta } from './raceDistance.js';
16
16
  import { isRaceDnf } from './retirements.js';
17
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
17
18
 
18
19
  export function recalculateRaceStateForSimulation(sim, { updateDrs = true } = {}) {
19
20
  sim.cars.forEach((car) => {
20
21
  const previousRaceDistance = car.raceDistance;
21
22
  if (car.destroyed || car.outOfRace) {
22
23
  applyWheelSurfaceState(car, sim.track);
23
- car.speed = 0;
24
+ freezeVehicleMotion(car, {
25
+ clearManualControls: true,
26
+ physicsMode: sim.physicsMode,
27
+ stabilityState: car.destroyed ? 'destroyed' : car.stabilityState,
28
+ });
24
29
  car.canAttack = false;
25
30
  car.drsEligible = false;
26
31
  car.drsActive = false;
@@ -40,7 +40,7 @@ export function runRaceStep(simulation, dt) {
40
40
  ];
41
41
  driveCars.forEach((car) => {
42
42
  if (car.destroyed || car.outOfRace) {
43
- freezeRetiredCar(car);
43
+ freezeRetiredCar(car, { physicsMode: simulation.physicsMode });
44
44
  return;
45
45
  }
46
46
  const orderIndex = orderedIndexById.get(car.id);
@@ -1,11 +1,11 @@
1
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
2
+
1
3
  export function applyRedFlagHoldForSimulation(sim) {
2
4
  sim.cars.forEach((car) => {
3
5
  car.previousX = car.x;
4
6
  car.previousY = car.y;
5
7
  car.previousHeading = car.heading;
6
- car.speed = 0;
7
- car.throttle = 0;
8
- car.brake = 1;
8
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
9
9
  car.drsActive = false;
10
10
  car.drsEligible = false;
11
11
  car.drsZoneId = null;
@@ -1,5 +1,6 @@
1
1
  import { pointAt } from '../track/trackModel.js';
2
2
  import { kphToSimSpeed } from '../units.js';
3
+ import { freezeVehicleMotion, syncVehicleVelocityToHeading } from '../vehicle/vehicleKinematics.js';
3
4
 
4
5
  export function setSafetyCarState(sim, deployed) {
5
6
  const next = Boolean(deployed);
@@ -60,9 +61,7 @@ export function setRedFlagState(sim, deployed) {
60
61
  sim.raceControl.mode = 'red-flag';
61
62
  sim.raceControl.frozenOrder = sim.orderedCars().map((car) => car.id);
62
63
  sim.cars.forEach((car) => {
63
- car.speed = 0;
64
- car.throttle = 0;
65
- car.brake = 1;
64
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
66
65
  car.drsActive = false;
67
66
  car.drsEligible = false;
68
67
  car.drsZoneId = null;
@@ -75,8 +74,15 @@ export function setRedFlagState(sim, deployed) {
75
74
  ? sim.orderedCars().map((car) => car.id)
76
75
  : null;
77
76
  sim.cars.forEach((car) => {
78
- if (!car.finished) {
77
+ if (car.destroyed || car.outOfRace) {
78
+ freezeVehicleMotion(car, {
79
+ clearManualControls: true,
80
+ physicsMode: sim.physicsMode,
81
+ stabilityState: car.destroyed ? 'destroyed' : car.stabilityState,
82
+ });
83
+ } else if (!car.finished) {
79
84
  car.speed = Math.max(car.speed, kphToSimSpeed(60));
85
+ if (sim.physicsMode === 'simulator') syncVehicleVelocityToHeading(car);
80
86
  car.brake = 0;
81
87
  car.throttle = Math.max(car.throttle ?? 0, 0.35);
82
88
  }
@@ -1,5 +1,6 @@
1
1
  import { markCarDnf } from './retirements.js';
2
2
  import { simSpeedToKph } from '../units.js';
3
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
3
4
 
4
5
  const LEGAL_STALL_SURFACES = new Set(['track', 'kerb', 'pit-entry', 'pit-lane', 'pit-exit', 'pit-box']);
5
6
 
@@ -48,7 +49,7 @@ function retireStalledCar(sim, car) {
48
49
  if (car.finished || car.destroyed || car.outOfRace) return;
49
50
  car.outOfRace = true;
50
51
  markCarDnf(sim, car, { reason: 'stalled-off-track' });
51
- freezeRetiredCar(car);
52
+ freezeRetiredCar(car, { physicsMode: sim.physicsMode });
52
53
  sim.events.unshift({
53
54
  type: 'car-dnf',
54
55
  at: sim.time,
@@ -58,27 +59,17 @@ function retireStalledCar(sim, car) {
58
59
  });
59
60
  }
60
61
 
61
- export function freezeRetiredCar(car) {
62
- car.speed = 0;
63
- car.velocityX = 0;
64
- car.velocityY = 0;
65
- car.throttle = 0;
66
- car.brake = 1;
67
- car.appliedControls = { steering: 0, throttle: 0, brake: 1 };
68
- car.manualControls = null;
62
+ export function freezeRetiredCar(car, { physicsMode = 'simulator' } = {}) {
63
+ freezeVehicleMotion(car, {
64
+ clearManualControls: true,
65
+ physicsMode,
66
+ stabilityState: 'destroyed',
67
+ });
69
68
  car.canAttack = false;
70
69
  car.drsActive = false;
71
70
  car.drsEligible = false;
72
71
  car.drsZoneId = null;
73
72
  car.drsZoneEnabled = false;
74
- car.steeringAngle = 0;
75
- car.yawRate = 0;
76
- car.longitudinalAcceleration = 0;
77
- car.lateralAcceleration = 0;
78
- car.longitudinalG = 0;
79
- car.lateralG = 0;
80
- car.tractionLimited = false;
81
- car.stabilityState = 'destroyed';
82
73
  }
83
74
 
84
75
  function resetStallTimer(car) {
@@ -152,6 +152,7 @@ export class F1RaceSimulation {
152
152
  cars: this.cars,
153
153
  computeLap: (raceDistance) => this.computeLap(raceDistance),
154
154
  nearestDistanceOnRoute,
155
+ physicsMode: this.physicsMode,
155
156
  progressDelta,
156
157
  raceControl: this.raceControl,
157
158
  releaseRaceStart: () => this.releaseRaceStart(),
@@ -11,6 +11,9 @@ function forwardVector(car) {
11
11
  }
12
12
 
13
13
  function velocityVector(car) {
14
+ if (Number.isFinite(car.velocityX) && Number.isFinite(car.velocityY)) {
15
+ return { x: car.velocityX, y: car.velocityY };
16
+ }
14
17
  const forward = forwardVector(car);
15
18
  return { x: forward.x * car.speed, y: forward.y * car.speed };
16
19
  }
@@ -47,6 +47,7 @@ export function createVehicleSnapshotDependencies(sim) {
47
47
  simSpeedToKph,
48
48
  simUnitsToMeters,
49
49
  VEHICLE_LIMITS,
50
+ physicsMode: sim.physicsMode,
50
51
  };
51
52
  }
52
53
 
@@ -0,0 +1,94 @@
1
+ import { clamp } from '../simMath.js';
2
+ import { ensureQueryScratch, ensureScratchArray, nextScratchEpoch } from './trackQueryScratch.js';
3
+
4
+ export function createSpatialGrid(bounds, cellSize) {
5
+ const columns = Math.max(1, Math.ceil((bounds.maxX - bounds.minX) / cellSize));
6
+ const rows = Math.max(1, Math.ceil((bounds.maxY - bounds.minY) / cellSize));
7
+ return {
8
+ bounds,
9
+ cellSize,
10
+ columns,
11
+ rows,
12
+ cells: new Array(columns * rows),
13
+ };
14
+ }
15
+
16
+ export function insertIdIntoGridBounds(grid, id, bounds) {
17
+ const minCell = gridCellForPoint(grid, { x: bounds.minX, y: bounds.minY }, true);
18
+ const maxCell = gridCellForPoint(grid, { x: bounds.maxX, y: bounds.maxY }, true);
19
+ for (let row = minCell.row; row <= maxCell.row; row += 1) {
20
+ for (let column = minCell.column; column <= maxCell.column; column += 1) {
21
+ const cellIndex = row * grid.columns + column;
22
+ const cell = grid.cells[cellIndex];
23
+ if (cell) cell.push(id);
24
+ else grid.cells[cellIndex] = [id];
25
+ }
26
+ }
27
+ }
28
+
29
+ export function candidateIdsFromGrid(index, grid, position, neighborLimit) {
30
+ const center = gridCellForPoint(grid, position, false);
31
+ if (!center) return [];
32
+ const scratch = ensureQueryScratch(index);
33
+ const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
34
+ const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
35
+ const ids = [];
36
+ for (let radius = 0; radius <= neighborLimit; radius += 1) {
37
+ const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
38
+ ids.length = 0;
39
+ for (let row = center.row - radius; row <= center.row + radius; row += 1) {
40
+ for (let column = center.column - radius; column <= center.column + radius; column += 1) {
41
+ if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows) continue;
42
+ const cell = grid.cells[row * grid.columns + column];
43
+ if (!cell) continue;
44
+ for (const id of cell) {
45
+ if (idMarks[id] === candidateEpoch) continue;
46
+ idMarks[id] = candidateEpoch;
47
+ ids.push(id);
48
+ }
49
+ }
50
+ }
51
+ if (ids.length) return ids.slice();
52
+ }
53
+ return [];
54
+ }
55
+
56
+ export function candidateIdsFromGridBounds(index, grid, bounds) {
57
+ if (
58
+ bounds.maxX < grid.bounds.minX ||
59
+ bounds.minX > grid.bounds.maxX ||
60
+ bounds.maxY < grid.bounds.minY ||
61
+ bounds.minY > grid.bounds.maxY
62
+ ) return [];
63
+ const minPoint = { x: bounds.minX, y: bounds.minY };
64
+ const maxPoint = { x: bounds.maxX, y: bounds.maxY };
65
+ const minCell = gridCellForPoint(grid, minPoint, true);
66
+ const maxCell = gridCellForPoint(grid, maxPoint, true);
67
+ const scratch = ensureQueryScratch(index);
68
+ const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
69
+ const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
70
+ const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
71
+ const ids = [];
72
+ for (let row = minCell.row; row <= maxCell.row; row += 1) {
73
+ for (let column = minCell.column; column <= maxCell.column; column += 1) {
74
+ const cell = grid.cells[row * grid.columns + column];
75
+ if (!cell) continue;
76
+ for (const id of cell) {
77
+ if (idMarks[id] === candidateEpoch) continue;
78
+ idMarks[id] = candidateEpoch;
79
+ ids.push(id);
80
+ }
81
+ }
82
+ }
83
+ return ids;
84
+ }
85
+
86
+ export function gridCellForPoint(grid, point, clampToGrid) {
87
+ const column = Math.floor((point.x - grid.bounds.minX) / grid.cellSize);
88
+ const row = Math.floor((point.y - grid.bounds.minY) / grid.cellSize);
89
+ if (!clampToGrid && (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows)) return null;
90
+ return {
91
+ column: clampToGrid ? clamp(column, 0, grid.columns - 1) : column,
92
+ row: clampToGrid ? clamp(row, 0, grid.rows - 1) : row,
93
+ };
94
+ }