@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
@@ -7,9 +7,9 @@ import { buildBoundarySenses } from './sensors/boundarySenses.js';
7
7
  import { buildContactPatchSenses } from './sensors/contactSenses.js';
8
8
  import { enrichOpponentRadar } from './sensors/opponentRadar.js';
9
9
  import { buildNearbyCars, buildRaySensors, createRayBatchContext, normalizeRayOptions } from './sensors.js';
10
+ import { buildObservationVector } from './observationVector.js';
10
11
 
11
12
  const EMPTY_EVENTS = Object.freeze([]);
12
- const PHYSICAL_RAY_SURFACE_CHANNELS = Object.freeze(['kerb', 'illegalSurface']);
13
13
 
14
14
  export function buildEnvironmentObservation({ snapshot, options, events = [], controlledDrivers = options.controlledDrivers }) {
15
15
  const carsById = new Map(snapshot.cars.map((entry) => [entry.id, entry]));
@@ -171,168 +171,7 @@ function isCarLegallyOnTrack(car) {
171
171
  }
172
172
 
173
173
  function buildDriverVector(object, sensors, { includeSchema = true, vectorType = 'array' } = {}) {
174
- const includePhysicalDriverSenses = object.profile === 'physical-driver';
175
- const schema = includeSchema ? [
176
- { name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
177
- { name: 'self.speedMetersPerSecond', unit: 'm/s', scale: 'fixed:120' },
178
- { name: 'self.steeringAngleRadians', unit: 'rad', scale: 'fixed:pi' },
179
- { name: 'self.throttle', scale: '0..1' },
180
- { name: 'self.brake', scale: '0..1' },
181
- { name: 'self.lateralG', scale: 'fixed:8g' },
182
- { name: 'self.longitudinalG', scale: 'fixed:6g' },
183
- { name: 'self.gripUsage', scale: '0..2' },
184
- { name: 'self.slipAngleRadians', unit: 'rad', scale: 'fixed:pi' },
185
- { name: 'self.tractionLimited', scale: 'boolean' },
186
- { name: 'self.lapProgressRatio', scale: '0..1' },
187
- { name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
188
- { name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
189
- { name: 'self.onTrack', scale: 'boolean' },
190
- { name: 'self.inPitLane', scale: 'boolean' },
191
- { name: 'self.tireEnergy', scale: '0..100' },
192
- { name: 'self.pitIntent', scale: '0..2' },
193
- { name: 'self.pitStopActive', scale: 'boolean' },
194
- { name: 'race.positionNormalized', scale: '0..1' },
195
- { name: 'race.raceModeGreen', scale: 'boolean' },
196
- { name: 'race.raceModeSafetyCar', scale: 'boolean' },
197
- { name: 'race.redFlag', scale: 'boolean' },
198
- { name: 'race.pitLaneOpen', scale: 'boolean' },
199
- { name: 'track.curvature', scale: 'track-curvature' },
200
- ] : null;
201
- const vector = [
202
- object.self.speedKph / 400,
203
- object.self.speedMetersPerSecond / 120,
204
- object.self.steeringAngleRadians / Math.PI,
205
- object.self.throttle,
206
- object.self.brake,
207
- object.self.lateralG / 8,
208
- object.self.longitudinalG / 6,
209
- object.self.gripUsage / 2,
210
- object.self.slipAngleRadians / Math.PI,
211
- object.self.tractionLimited ? 1 : 0,
212
- normalizeLapProgress(object),
213
- object.self.trackOffsetMeters,
214
- object.self.trackHeadingErrorRadians / Math.PI,
215
- object.self.onTrack ? 1 : 0,
216
- object.self.inPitLane ? 1 : 0,
217
- (object.self.tireEnergy ?? 0) / 100,
218
- (object.self.pitIntent ?? 0) / 2,
219
- object.self.pitStopStatus && object.self.pitStopStatus !== 'pending' && object.self.pitStopStatus !== 'completed' ? 1 : 0,
220
- normalizeRacePosition(object),
221
- object.race.raceMode === 'green' ? 1 : 0,
222
- object.race.raceMode === 'safety-car' ? 1 : 0,
223
- object.race.redFlag ? 1 : 0,
224
- object.race.pitLaneOpen ? 1 : 0,
225
- object.track.curvature ?? 0,
226
- ];
227
- if (includePhysicalDriverSenses) {
228
- pushSchema(schema,
229
- { name: 'self.yawRateRadiansPerSecond', unit: 'rad/s', scale: 'fixed:pi' },
230
- { name: 'trackRelation.leftBoundaryMeters', unit: 'm', scale: 'fixed:meters' },
231
- { name: 'trackRelation.rightBoundaryMeters', unit: 'm', scale: 'fixed:meters' },
232
- { name: 'trackRelation.legalWidthMeters', unit: 'm', scale: 'fixed:meters' },
233
- );
234
- vector.push(
235
- object.self.yawRateRadiansPerSecond / Math.PI,
236
- object.trackRelation.leftBoundaryMeters,
237
- object.trackRelation.rightBoundaryMeters,
238
- object.trackRelation.legalWidthMeters,
239
- );
240
- object.contactPatches.forEach((patch, index) => {
241
- pushSchema(schema,
242
- { name: `contactPatches[${index}].present`, scale: 'boolean' },
243
- { name: `contactPatches[${index}].surfaceCode`, scale: 'surface-code' },
244
- { name: `contactPatches[${index}].onLegalSurface`, scale: 'boolean' },
245
- { name: `contactPatches[${index}].signedOffsetMeters`, unit: 'm', scale: 'fixed:meters' },
246
- );
247
- vector.push(
248
- patch.present ? 1 : 0,
249
- patch.surfaceCode / 5,
250
- patch.onLegalSurface ? 1 : 0,
251
- patch.signedOffsetMeters,
252
- );
253
- });
254
- }
255
- object.track.lookahead.forEach((sample, index) => {
256
- pushSchema(schema,
257
- { name: `track.lookahead[${index}].curvature`, scale: 'track-curvature' },
258
- { name: `track.lookahead[${index}].headingDeltaRadians`, unit: 'rad', scale: 'fixed:pi' },
259
- );
260
- vector.push(sample.curvature ?? 0, (sample.headingDeltaRadians ?? 0) / Math.PI);
261
- });
262
- object.rays.forEach((ray, index) => {
263
- pushSchema(schema,
264
- { name: `rays[${index}].track.distanceRatio`, scale: '0..1' },
265
- { name: `rays[${index}].track.hit`, scale: 'boolean' },
266
- { name: `rays[${index}].track.kindExit`, scale: 'boolean' },
267
- { name: `rays[${index}].track.kindEntry`, scale: 'boolean' },
268
- { name: `rays[${index}].car.distanceRatio`, scale: '0..1' },
269
- { name: `rays[${index}].car.hit`, scale: 'boolean' },
270
- { name: `rays[${index}].car.relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
271
- );
272
- vector.push(
273
- ratio(ray.track.distanceMeters, ray.lengthMeters),
274
- ray.track.hit ? 1 : 0,
275
- ray.track.kind === 'exit' ? 1 : 0,
276
- ray.track.kind === 'entry' ? 1 : 0,
277
- ratio(ray.car.distanceMeters, ray.lengthMeters),
278
- ray.car.hit ? 1 : 0,
279
- ray.car.relativeSpeedKph / 200,
280
- );
281
- if (includePhysicalDriverSenses) {
282
- PHYSICAL_RAY_SURFACE_CHANNELS.forEach((channel) => {
283
- pushSchema(schema,
284
- { name: `rays[${index}].${channel}.distanceRatio`, scale: '0..1' },
285
- { name: `rays[${index}].${channel}.hit`, scale: 'boolean' },
286
- );
287
- vector.push(
288
- ratio(ray[channel]?.distanceMeters ?? ray.lengthMeters, ray.lengthMeters),
289
- ray[channel]?.hit ? 1 : 0,
290
- );
291
- });
292
- }
293
- });
294
- const nearbyLimit = sensors.nearbyCars.enabled ? (sensors.nearbyCars.maxCars ?? object.nearbyCars.length) : 0;
295
- const nearbyRadius = sensors.nearbyCars.radiusMeters ?? 150;
296
- for (let index = 0; index < nearbyLimit; index += 1) {
297
- const nearby = object.nearbyCars[index] ?? null;
298
- pushSchema(schema,
299
- { name: `nearbyCars[${index}].present`, scale: 'boolean' },
300
- { name: `nearbyCars[${index}].relativeForwardRatio`, scale: '-1..1' },
301
- { name: `nearbyCars[${index}].relativeRightRatio`, scale: '-1..1' },
302
- { name: `nearbyCars[${index}].relativeDistanceRatio`, scale: '0..1' },
303
- { name: `nearbyCars[${index}].relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
304
- { name: `nearbyCars[${index}].relativeHeadingRadians`, unit: 'rad', scale: 'fixed:pi' },
305
- { name: `nearbyCars[${index}].ahead`, scale: 'boolean' },
306
- { name: `nearbyCars[${index}].sameLap`, scale: 'boolean' },
307
- );
308
- vector.push(
309
- nearby ? 1 : 0,
310
- clampRatio((nearby?.relativeForwardMeters ?? 0) / nearbyRadius),
311
- clampRatio((nearby?.relativeRightMeters ?? 0) / nearbyRadius),
312
- ratio(nearby?.relativeDistanceMeters ?? nearbyRadius, nearbyRadius),
313
- (nearby?.relativeSpeedKph ?? 0) / 200,
314
- (nearby?.relativeHeadingRadians ?? 0) / Math.PI,
315
- nearby?.ahead ? 1 : 0,
316
- nearby?.sameLap ? 1 : 0,
317
- );
318
- if (includePhysicalDriverSenses) {
319
- pushSchema(schema,
320
- { name: `nearbyCars[${index}].behind`, scale: 'boolean' },
321
- { name: `nearbyCars[${index}].closingRateMetersPerSecond`, unit: 'm/s', scale: 'fixed:100' },
322
- { name: `nearbyCars[${index}].timeToContactSeconds`, unit: 's', scale: 'fixed:10' },
323
- { name: `nearbyCars[${index}].leftOverlap`, scale: 'boolean' },
324
- { name: `nearbyCars[${index}].rightOverlap`, scale: 'boolean' },
325
- );
326
- vector.push(
327
- nearby?.behind ? 1 : 0,
328
- (nearby?.closingRateMetersPerSecond ?? 0) / 100,
329
- ratio(nearby?.timeToContactSeconds ?? 10, 10),
330
- nearby?.leftOverlap ? 1 : 0,
331
- nearby?.rightOverlap ? 1 : 0,
332
- );
333
- }
334
- }
335
- return { vector: finalizeVector(vector, vectorType), schema: schema ?? [] };
174
+ return buildObservationVector(object, sensors, { includeSchema, vectorType });
336
175
  }
337
176
 
338
177
  function buildDriverVectorDirect(car, snapshot, options, events, sensors, getRayBatchContext = null) {
@@ -353,95 +192,40 @@ function buildDriverVectorDirect(car, snapshot, options, events, sensors, getRay
353
192
  : [];
354
193
  const trackLengthMeters = simUnitsToMeters(snapshot.track.length ?? 0);
355
194
  const trackCurvature = car.trackState?.curvature ?? pointAt(snapshot.track, car.progress ?? 0).curvature ?? 0;
356
- const vector = [
357
- body.speedKph / 400,
358
- body.speedMetersPerSecond / 120,
359
- body.steeringAngleRadians / Math.PI,
360
- body.throttle,
361
- body.brake,
362
- body.lateralG / 8,
363
- body.longitudinalG / 6,
364
- body.gripUsage / 2,
365
- body.slipAngleRadians / Math.PI,
366
- body.tractionLimited ? 1 : 0,
367
- ratio(simUnitsToMeters(car.progress ?? 0), trackLengthMeters || 1),
368
- trackRelation.lateralOffsetMeters,
369
- trackHeadingError / Math.PI,
370
- onTrack ? 1 : 0,
371
- car.inPitLane ? 1 : 0,
372
- (car.tireEnergy ?? 0) / 100,
373
- (car.pitIntent ?? car.pitStop?.intent ?? 0) / 2,
374
- car.pitStop?.status && car.pitStop.status !== 'pending' && car.pitStop.status !== 'completed' ? 1 : 0,
375
- snapshot.cars.length <= 1 ? 0 : ((car.rank ?? 1) - 1) / Math.max(1, snapshot.cars.length - 1),
376
- snapshot.raceControl.mode === 'green' ? 1 : 0,
377
- snapshot.raceControl.mode === 'safety-car' ? 1 : 0,
378
- snapshot.raceControl.redFlag ? 1 : 0,
379
- snapshot.raceControl.pitLaneOpen ? 1 : 0,
380
- trackCurvature,
381
- ];
382
- if (includePhysicalDriverSenses) {
383
- vector.push(
384
- body.yawRateRadiansPerSecond / Math.PI,
385
- trackRelation.leftBoundaryMeters,
386
- trackRelation.rightBoundaryMeters,
387
- trackRelation.legalWidthMeters,
388
- );
389
- contactPatches.forEach((patch) => {
390
- vector.push(
391
- patch.present ? 1 : 0,
392
- patch.surfaceCode / 5,
393
- patch.onLegalSurface ? 1 : 0,
394
- patch.signedOffsetMeters,
395
- );
396
- });
397
- }
398
- buildTrackLookahead(car, snapshot, options).forEach((sample) => {
399
- vector.push(sample.curvature ?? 0, (sample.headingDeltaRadians ?? 0) / Math.PI);
400
- });
401
- rays.forEach((ray) => {
402
- vector.push(
403
- ratio(ray.track.distanceMeters, ray.lengthMeters),
404
- ray.track.hit ? 1 : 0,
405
- ray.track.kind === 'exit' ? 1 : 0,
406
- ray.track.kind === 'entry' ? 1 : 0,
407
- ratio(ray.car.distanceMeters, ray.lengthMeters),
408
- ray.car.hit ? 1 : 0,
409
- ray.car.relativeSpeedKph / 200,
410
- );
411
- if (includePhysicalDriverSenses) {
412
- PHYSICAL_RAY_SURFACE_CHANNELS.forEach((channel) => {
413
- vector.push(
414
- ratio(ray[channel]?.distanceMeters ?? ray.lengthMeters, ray.lengthMeters),
415
- ray[channel]?.hit ? 1 : 0,
416
- );
417
- });
418
- }
195
+ const source = {
196
+ profile,
197
+ self: {
198
+ ...body,
199
+ lapProgressMeters: simUnitsToMeters(car.progress ?? 0),
200
+ trackOffsetMeters: trackRelation.lateralOffsetMeters,
201
+ trackHeadingErrorRadians: trackHeadingError,
202
+ onTrack,
203
+ inPitLane: Boolean(car.inPitLane),
204
+ tireEnergy: car.tireEnergy ?? null,
205
+ pitIntent: car.pitIntent ?? car.pitStop?.intent ?? 0,
206
+ pitStopStatus: car.pitStop?.status ?? null,
207
+ },
208
+ trackRelation,
209
+ contactPatches,
210
+ race: {
211
+ position: car.rank,
212
+ totalCars: snapshot.cars.length,
213
+ raceMode: snapshot.raceControl.mode,
214
+ pitLaneOpen: Boolean(snapshot.raceControl.pitLaneOpen),
215
+ redFlag: Boolean(snapshot.raceControl.redFlag),
216
+ },
217
+ track: {
218
+ lengthMeters: trackLengthMeters,
219
+ curvature: trackCurvature,
220
+ lookahead: buildTrackLookahead(car, snapshot, options),
221
+ },
222
+ rays,
223
+ nearbyCars,
224
+ };
225
+ return buildObservationVector(source, sensors, {
226
+ includeSchema: false,
227
+ vectorType: options.observation?.vectorType,
419
228
  });
420
- const nearbyLimit = sensors.nearbyCars.enabled ? (sensors.nearbyCars.maxCars ?? nearbyCars.length) : 0;
421
- const nearbyRadius = sensors.nearbyCars.radiusMeters ?? 150;
422
- for (let index = 0; index < nearbyLimit; index += 1) {
423
- const nearby = nearbyCars[index] ?? null;
424
- vector.push(
425
- nearby ? 1 : 0,
426
- clampRatio((nearby?.relativeForwardMeters ?? 0) / nearbyRadius),
427
- clampRatio((nearby?.relativeRightMeters ?? 0) / nearbyRadius),
428
- ratio(nearby?.relativeDistanceMeters ?? nearbyRadius, nearbyRadius),
429
- (nearby?.relativeSpeedKph ?? 0) / 200,
430
- (nearby?.relativeHeadingRadians ?? 0) / Math.PI,
431
- nearby?.ahead ? 1 : 0,
432
- nearby?.sameLap ? 1 : 0,
433
- );
434
- if (includePhysicalDriverSenses) {
435
- vector.push(
436
- nearby?.behind ? 1 : 0,
437
- (nearby?.closingRateMetersPerSecond ?? 0) / 100,
438
- ratio(nearby?.timeToContactSeconds ?? 10, 10),
439
- nearby?.leftOverlap ? 1 : 0,
440
- nearby?.rightOverlap ? 1 : 0,
441
- );
442
- }
443
- }
444
- return { vector: finalizeVector(vector, options.observation?.vectorType), schema: [] };
445
229
  }
446
230
 
447
231
  function rayBatchContextForSensors(sensors, getRayBatchContext) {
@@ -449,48 +233,18 @@ function rayBatchContextForSensors(sensors, getRayBatchContext) {
449
233
  return getRayBatchContext();
450
234
  }
451
235
 
452
- function finalizeVector(vector, vectorType = 'array') {
453
- return vectorType === 'float32' ? Float32Array.from(vector) : vector;
454
- }
455
-
456
- function pushSchema(schema, ...entries) {
457
- if (schema) schema.push(...entries);
458
- }
459
-
460
- function normalizeLapProgress(object) {
461
- return ratio(object.self.lapProgressMeters, object.track.lengthMeters || 1);
462
- }
463
-
464
- function normalizeRacePosition(object) {
465
- const totalCars = Number(object.race.totalCars);
466
- const position = Number(object.race.position);
467
- if (!Number.isFinite(totalCars) || totalCars <= 1 || !Number.isFinite(position)) return 0;
468
- return ratio(position - 1, totalCars - 1);
469
- }
470
-
471
- function ratio(value, max) {
472
- const finite = Number.isFinite(value) ? value : max;
473
- return Math.max(0, Math.min(1, finite / Math.max(1e-9, max)));
474
- }
475
-
476
- function clampRatio(value) {
477
- return Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
478
- }
479
-
480
- function defaultSensorOptions(options) {
236
+ export function defaultSensorOptions(options) {
481
237
  return {
482
238
  rays: normalizeRayOptions(options.sensors.rays),
483
239
  nearbyCars: options.sensors.nearbyCars,
484
240
  };
485
241
  }
486
242
 
487
- function effectiveSensorOptions(options, driverId, defaultSensors = defaultSensorOptions(options), hasSensorOverrides = true) {
243
+ export function effectiveSensorOptions(options, driverId, defaultSensors = defaultSensorOptions(options), hasSensorOverrides = true) {
488
244
  if (!hasSensorOverrides || !options.sensorsByDriver?.[driverId]) return defaultSensors;
245
+ const rayOverrides = options.sensorsByDriver?.[driverId]?.rays ?? {};
489
246
  return {
490
- rays: normalizeRayOptions({
491
- ...options.sensors.rays,
492
- ...(options.sensorsByDriver?.[driverId]?.rays ?? {}),
493
- }),
247
+ rays: normalizeRayOptions(mergeDriverRayOptions(options.sensors.rays, rayOverrides)),
494
248
  nearbyCars: {
495
249
  ...options.sensors.nearbyCars,
496
250
  ...(options.sensorsByDriver?.[driverId]?.nearbyCars ?? {}),
@@ -498,6 +252,25 @@ function effectiveSensorOptions(options, driverId, defaultSensors = defaultSenso
498
252
  };
499
253
  }
500
254
 
255
+ function mergeDriverRayOptions(baseRays, rayOverrides = {}) {
256
+ const merged = {
257
+ ...baseRays,
258
+ ...rayOverrides,
259
+ };
260
+ if (Object.hasOwn(rayOverrides, 'rays')) return merged;
261
+ if (Object.hasOwn(rayOverrides, 'layout') || Object.hasOwn(rayOverrides, 'anglesDegrees')) {
262
+ delete merged.rays;
263
+ return merged;
264
+ }
265
+ if (Object.hasOwn(rayOverrides, 'lengthMeters') || Object.hasOwn(rayOverrides, 'defaultLengthMeters')) {
266
+ merged.rays = (baseRays.rays ?? []).map((ray) => ({
267
+ id: ray.id,
268
+ angleDegrees: ray.angleDegrees,
269
+ }));
270
+ }
271
+ return merged;
272
+ }
273
+
501
274
  function emptyObservation(driverId) {
502
275
  return {
503
276
  object: {
@@ -10,19 +10,26 @@ export function createRolloutRecorder() {
10
10
  transitions.length = 0;
11
11
  },
12
12
  toJSON() {
13
- return transitions.map((entry) => ({ ...entry }));
13
+ return transitions.map((entry) => cloneTrainingValue(entry));
14
14
  },
15
15
  };
16
16
  }
17
17
 
18
18
  export function createRolloutTransition(previousResult, action, nextResult) {
19
19
  return {
20
- observation: previousResult.observation,
21
- action,
22
- reward: nextResult.reward,
23
- nextObservation: nextResult.observation,
20
+ observation: cloneTrainingValue(previousResult.observation),
21
+ action: cloneTrainingValue(action),
22
+ reward: cloneTrainingValue(nextResult.reward),
23
+ nextObservation: cloneTrainingValue(nextResult.observation),
24
24
  terminated: nextResult.terminated,
25
25
  truncated: nextResult.truncated,
26
- info: nextResult.info,
26
+ info: cloneTrainingValue(nextResult.info),
27
27
  };
28
28
  }
29
+
30
+ function cloneTrainingValue(value) {
31
+ if (value == null || typeof value !== 'object') return value;
32
+ if (ArrayBuffer.isView(value)) return Array.from(value);
33
+ if (Array.isArray(value)) return value.map(cloneTrainingValue);
34
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneTrainingValue(entry)]));
35
+ }
@@ -19,8 +19,10 @@ export function createProgressReward(options = {}) {
19
19
  const currentCar = findSnapshotCar(context.state?.snapshot, driverId);
20
20
  const progressMeters = getProgressMeters(previousCar, currentCar);
21
21
  const self = context.current?.object?.self ?? {};
22
- const speedKph = Number(self.speedKph ?? 0);
23
- const isOnTrack = self.onTrack !== false;
22
+ const speedKph = Number(self.speedKph ?? currentCar?.speedKph ?? 0);
23
+ const isOnTrack = context.metrics?.offTrack != null
24
+ ? !context.metrics.offTrack
25
+ : self.onTrack ?? isSnapshotCarOnTrack(currentCar);
24
26
  const collisionCount = countDriverCollisions(context.events, driverId);
25
27
  const steering = Math.abs(Number(context.action?.steering ?? 0));
26
28
  const brake = Math.abs(Number(context.action?.brake ?? 0));
@@ -49,6 +51,15 @@ function getProgressMeters(previousCar, currentCar) {
49
51
  return current - previous;
50
52
  }
51
53
 
54
+ function isSnapshotCarOnTrack(car) {
55
+ if (!car) return true;
56
+ if (car.inPitLane) return true;
57
+ if (Array.isArray(car.wheels) && car.wheels.length > 0) {
58
+ return car.wheels.every((wheel) => wheel.onTrack || wheel.inPitLane);
59
+ }
60
+ return ['track', 'kerb', 'pit-entry', 'pit-lane', 'pit-exit', 'pit-box'].includes(car.surface ?? 'track');
61
+ }
62
+
52
63
  function countDriverCollisions(events = [], driverId) {
53
64
  if (!driverId || !Array.isArray(events)) return 0;
54
65
  return events.filter((event) => isCollisionEvent(event) && eventIncludesDriver(event, driverId)).length;
@@ -47,7 +47,9 @@ function createSimulationWithEnvironmentScenario(options) {
47
47
  surface: 'environment',
48
48
  execute: ({ warmup }) => {
49
49
  const warmupSimulation = createRaceSimulation(disableWarmupOptions(simulationOptions, warmup.surface));
50
+ initializeControlledPitIntentForSimulation(warmupSimulation, options);
50
51
  applyEnvironmentScenario(warmupSimulation, options);
52
+ applyControlledRunoffResponse(warmupSimulation, options);
51
53
  for (let index = 0; index < warmup.steps; index += 1) warmupSimulation.step(FIXED_STEP);
52
54
  const warmupSnapshot = warmupSimulation.snapshotObservation?.() ?? warmupSimulation.snapshot();
53
55
  buildEnvironmentObservation({
@@ -58,7 +60,9 @@ function createSimulationWithEnvironmentScenario(options) {
58
60
  },
59
61
  });
60
62
  const sim = createRaceSimulation(disableWarmupOptions(simulationOptions, 'environment'));
63
+ initializeControlledPitIntentForSimulation(sim, options);
61
64
  applyEnvironmentScenario(sim, options);
65
+ applyControlledRunoffResponse(sim, options);
62
66
  return sim;
63
67
  }
64
68
 
@@ -120,7 +124,14 @@ export function createEnvironmentRuntime(host) {
120
124
  }
121
125
  episodeState.step += 1;
122
126
  advanceDriverEpisodes(episodeState, options.controlledDrivers);
123
- const result = buildResult({ host, episodeState, events: stepEvents, actionErrors: errors, actions });
127
+ const result = buildResult({
128
+ host,
129
+ episodeState,
130
+ events: stepEvents,
131
+ actionErrors: errors,
132
+ actions,
133
+ rewardEnabled: true,
134
+ });
124
135
  episodeState.lastResult = result;
125
136
  emitExternalRenderFrame(host, result, { source: 'step', actions });
126
137
  host.afterStep(result);
@@ -150,8 +161,9 @@ export function createEnvironmentRuntime(host) {
150
161
  Object.keys(normalizedPlacements).forEach((driverId) => {
151
162
  sim.clearCarControls?.(driverId);
152
163
  sim.setAutomaticPitIntentEnabled?.(driverId, false);
153
- sim.setPitIntent?.(driverId, 0);
154
164
  const car = sim.cars?.find?.((item) => item.id === driverId);
165
+ resetEnvironmentPitStop(car);
166
+ sim.setPitIntent?.(driverId, 0);
155
167
  if (car) sim.applyRunoffResponse?.(car);
156
168
  });
157
169
  resetDriverEpisodes(episodeState, Object.keys(normalizedPlacements));
@@ -208,6 +220,7 @@ function mergePlainObjects(base, overrides, path = []) {
208
220
  if (!isPlainObject(base) || !isPlainObject(overrides)) return overrides;
209
221
  const merged = { ...base };
210
222
  Object.entries(overrides).forEach(([key, value]) => {
223
+ if (value === undefined) return;
211
224
  const nextPath = [...path, key];
212
225
  merged[key] = isPlainObject(value) &&
213
226
  isPlainObject(base[key]) &&
@@ -235,6 +248,10 @@ function isNoopPitIntent(pitIntent) {
235
248
  function initializeControlledPitIntent(host) {
236
249
  const sim = host.getSimulation?.();
237
250
  const options = host.getOptions?.();
251
+ initializeControlledPitIntentForSimulation(sim, options);
252
+ }
253
+
254
+ function initializeControlledPitIntentForSimulation(sim, options) {
238
255
  options?.controlledDrivers?.forEach?.((driverId) => {
239
256
  sim?.setAutomaticPitIntentEnabled?.(driverId, false);
240
257
  sim?.setPitIntent?.(driverId, 0);
@@ -243,6 +260,24 @@ function initializeControlledPitIntent(host) {
243
260
  });
244
261
  }
245
262
 
263
+ function resetEnvironmentPitStop(car) {
264
+ const stop = car?.pitStop;
265
+ if (!stop) return;
266
+ stop.status = 'pending';
267
+ stop.phase = null;
268
+ stop.intent = 0;
269
+ stop.queueingForService = false;
270
+ stop.route = null;
271
+ stop.routeProgress = 0;
272
+ stop.routeStartRaceDistance = null;
273
+ stop.routeEndRaceDistance = null;
274
+ stop.serviceRemaining = 0;
275
+ stop.penaltyServiceRemaining = 0;
276
+ stop.penaltyServiceTotal = 0;
277
+ stop.servingPenaltyIds = [];
278
+ stop.serviceProfile = null;
279
+ }
280
+
246
281
  function buildResult({
247
282
  host,
248
283
  episodeState,
@@ -251,6 +286,7 @@ function buildResult({
251
286
  actions = {},
252
287
  controlledDrivers = null,
253
288
  stateOutput = null,
289
+ rewardEnabled = false,
254
290
  }) {
255
291
  const options = host.getOptions();
256
292
  const sim = host.getSimulation();
@@ -276,18 +312,23 @@ function buildResult({
276
312
  ...options,
277
313
  controlledDrivers: resultDrivers,
278
314
  }, episode);
279
- const driverEpisodeInfo = buildDriverEpisodeInfo(episodeState, options, episode);
315
+ const driverEpisode = resultDrivers === options.controlledDrivers
316
+ ? episode
317
+ : evaluateEpisode(observationSnapshot, options, episodeState, options.controlledDrivers);
318
+ const driverEpisodeInfo = buildDriverEpisodeInfo(episodeState, options, driverEpisode);
280
319
  const { state, rewardSnapshot } = buildResultState(sim, observationSnapshot, resolvedStateOutput);
281
- const reward = computeReward({
282
- options: { ...options, controlledDrivers: resultDrivers },
283
- observation,
284
- events,
285
- snapshot: rewardSnapshot,
286
- actions,
287
- previousSnapshot: episodeState.previousSnapshot,
288
- metrics,
289
- driverEpisodeInfo: rewardEpisodeInfo,
290
- });
320
+ const reward = rewardEnabled
321
+ ? computeReward({
322
+ options: { ...options, controlledDrivers: resultDrivers },
323
+ observation,
324
+ events,
325
+ snapshot: rewardSnapshot,
326
+ actions,
327
+ previousSnapshot: episodeState.previousSnapshot,
328
+ metrics,
329
+ driverEpisodeInfo: rewardEpisodeInfo,
330
+ })
331
+ : null;
291
332
  episodeState.lastObservationSnapshot = observationSnapshot;
292
333
  episodeState.lastRewardSnapshot = rewardSnapshot;
293
334
  return {
@@ -304,7 +345,7 @@ function buildResult({
304
345
  elapsedSeconds: observationSnapshot.time,
305
346
  seed: options.seed,
306
347
  trackSeed: options.trackSeed,
307
- controlledDrivers: [...options.controlledDrivers],
348
+ controlledDrivers: [...resultDrivers],
308
349
  actionErrors,
309
350
  endReason: episode.endReason,
310
351
  drivers: driverEpisodeInfo,
@@ -312,6 +353,13 @@ function buildResult({
312
353
  };
313
354
  }
314
355
 
356
+ function applyControlledRunoffResponse(sim, options) {
357
+ options?.controlledDrivers?.forEach?.((driverId) => {
358
+ const car = sim?.cars?.find?.((item) => item.id === driverId);
359
+ if (car) sim?.applyRunoffResponse?.(car);
360
+ });
361
+ }
362
+
315
363
  function buildResultState(sim, observationSnapshot, stateOutput) {
316
364
  if (stateOutput === 'none') return { state: null, rewardSnapshot: observationSnapshot };
317
365
  if (stateOutput === 'minimal') return { state: { snapshot: observationSnapshot }, rewardSnapshot: observationSnapshot };
@@ -182,7 +182,9 @@ export function applyEnvironmentPlacements(sim, track, carsById, placements = {}
182
182
  Object.entries(placements).forEach(([driverId, placement]) => {
183
183
  const currentCar = carsById.get(driverId);
184
184
  if (!currentCar) return;
185
- states[driverId] = placementToCarState(track, currentCar, placement);
185
+ states[driverId] = placementToCarState(track, currentCar, placement, {
186
+ physicsMode: sim.physicsMode,
187
+ });
186
188
  });
187
189
  if (typeof sim.setCarStates === 'function') {
188
190
  sim.setCarStates(states);
@@ -193,13 +195,14 @@ export function applyEnvironmentPlacements(sim, track, carsById, placements = {}
193
195
  });
194
196
  }
195
197
 
196
- function placementToCarState(track, currentCar, placement) {
198
+ function placementToCarState(track, currentCar, placement, { physicsMode = 'arcade' } = {}) {
197
199
  const currentDistanceMeters = currentCar.distanceMeters ?? simUnitsToMeters(currentCar.raceDistance ?? currentCar.progress ?? 0);
198
200
  const raceDistance = metersToSimUnits(Math.max(0, placement.distanceMeters ?? currentDistanceMeters));
199
201
  const offset = metersToSimUnits(placement.offsetMeters ?? 0);
200
202
  const base = pointAt(track, raceDistance);
201
203
  const position = offsetTrackPoint(base, offset);
202
204
  const heading = normalizeAngle(base.heading + (placement.headingErrorRadians ?? 0));
205
+ const speed = placement.speedKph == null ? null : kphToSimSpeed(placement.speedKph);
203
206
  const partial = {
204
207
  x: position.x,
205
208
  y: position.y,
@@ -217,11 +220,27 @@ function placementToCarState(track, currentCar, placement) {
217
220
  canAttack: true,
218
221
  throttle: 0,
219
222
  brake: 0,
223
+ longitudinalAcceleration: 0,
224
+ lateralAcceleration: 0,
225
+ lateralG: 0,
226
+ longitudinalG: 0,
227
+ gripUsage: 0,
228
+ slipAngleRadians: 0,
229
+ tractionLimited: false,
220
230
  steeringAngle: 0,
221
231
  yawRate: 0,
222
232
  stabilityState: 'stable',
223
233
  };
224
- if (placement.speedKph != null) partial.speed = kphToSimSpeed(placement.speedKph);
234
+ if (speed != null) {
235
+ partial.speed = speed;
236
+ if (physicsMode === 'simulator') {
237
+ partial.velocityX = Math.cos(heading) * speed;
238
+ partial.velocityY = Math.sin(heading) * speed;
239
+ } else {
240
+ partial.velocityX = null;
241
+ partial.velocityY = null;
242
+ }
243
+ }
225
244
  return partial;
226
245
  }
227
246