@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
@@ -16,17 +16,12 @@ import { PitLaneStatusRenderer } from './rendering/pitLaneStatusRenderer.js';
16
16
  import { ReplayGhostRenderer } from './rendering/replayGhostRenderer.js';
17
17
  import { renderTrackSurface } from './rendering/trackRenderer.js';
18
18
  import {
19
- formatLapGap,
20
- formatRaceGap,
21
- getPenaltyByDriver,
22
19
  getTimingOrderKey,
23
20
  getTimingPenaltyKey,
24
- isWavedFlagCar,
25
21
  renderTimingTower,
26
22
  syncTimingGapModeControls,
27
23
  } from './readouts/timingTowerRenderer.js';
28
- import { formatTelemetryGap } from './readouts/readoutFormatters.js';
29
- import { getOverviewFields, renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
24
+ import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
30
25
  import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
31
26
  import {
32
27
  renderRaceFinish,
@@ -327,18 +322,6 @@ export class F1SimulatorApp {
327
322
  });
328
323
  }
329
324
 
330
- createRawCarGeometryGraphic(driver) {
331
- return this.carRenderer.createRawCarGeometryGraphic(driver);
332
- }
333
-
334
- createServiceCountdownLabel() {
335
- return this.carRenderer.createServiceCountdownLabel();
336
- }
337
-
338
- applyServiceCountdownTone(label, tone) {
339
- this.carRenderer.applyServiceCountdownTone(label, tone);
340
- }
341
-
342
325
  bindControls() {
343
326
  const eventOptions = { signal: this.abortController.signal };
344
327
 
@@ -838,32 +821,12 @@ export class F1SimulatorApp {
838
821
  return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
839
822
  }
840
823
 
841
- isWavedFlagCar(car) {
842
- return isWavedFlagCar(car);
843
- }
844
-
845
- getPenaltyByDriver(penalties = []) {
846
- return getPenaltyByDriver(penalties);
847
- }
848
-
849
824
  getTimingPenaltyKey(penalties = []) {
850
825
  return getTimingPenaltyKey(penalties, {
851
826
  timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
852
827
  });
853
828
  }
854
829
 
855
- formatTimingGap(car) {
856
- return formatRaceGap(car, this.timingGapMode);
857
- }
858
-
859
- formatLapGap(laps) {
860
- return formatLapGap(laps);
861
- }
862
-
863
- formatTelemetryGap(car, mode) {
864
- return formatTelemetryGap(car, mode);
865
- }
866
-
867
830
  syncTimingGapModeControls() {
868
831
  syncTimingGapModeControls({
869
832
  readouts: this.readouts,
@@ -886,10 +849,6 @@ export class F1SimulatorApp {
886
849
  renderLapTelemetry(this.readouts, telemetry);
887
850
  }
888
851
 
889
- getOverviewFields(driver, mode) {
890
- return getOverviewFields(driver, mode);
891
- }
892
-
893
852
  renderCarDriverOverview(car) {
894
853
  const previousKey = this.lastOverviewRenderKey;
895
854
  this.lastOverviewRenderKey = renderCarDriverOverview({
@@ -0,0 +1,34 @@
1
+ export function mergeRestartOptions(previousOptions, nextOptions = {}) {
2
+ const resetFromPreset = Object.hasOwn(nextOptions, 'preset');
3
+ const previousUi = resetFromPreset ? {} : (previousOptions.ui ?? {});
4
+ const previousTheme = resetFromPreset ? {} : (previousOptions.theme ?? {});
5
+ const previousAssets = previousOptions.assets ?? {};
6
+ const nextAssets = nextOptions.assets ?? {};
7
+
8
+ return {
9
+ ...previousOptions,
10
+ ...nextOptions,
11
+ ui: {
12
+ ...previousUi,
13
+ ...(nextOptions.ui ?? {}),
14
+ raceDataBanners: {
15
+ ...(previousUi.raceDataBanners ?? {}),
16
+ ...(nextOptions.ui?.raceDataBanners ?? {}),
17
+ },
18
+ },
19
+ theme: {
20
+ ...previousTheme,
21
+ ...(nextOptions.theme ?? {}),
22
+ },
23
+ assets: {
24
+ ...previousAssets,
25
+ ...nextAssets,
26
+ trackTextures: {
27
+ ...(previousAssets.trackTextures ?? {}),
28
+ ...(nextAssets.trackTextures ?? {}),
29
+ },
30
+ },
31
+ drivers: nextOptions.drivers ?? previousOptions.drivers,
32
+ entries: nextOptions.entries ?? previousOptions.entries,
33
+ };
34
+ }
@@ -22,6 +22,7 @@ export function createPaddockDriverControllerLoop({
22
22
  let heldFramesRemaining = 0;
23
23
  let policyStep = 0;
24
24
  let runtimeStep = 0;
25
+ let observationCache = {};
25
26
  let running = false;
26
27
  let scheduled = null;
27
28
  let previousActions = {};
@@ -33,6 +34,11 @@ export function createPaddockDriverControllerLoop({
33
34
  if (!observationSpec) observationSpec = runtime.getObservationSpec();
34
35
  }
35
36
 
37
+ async function refreshSpecs() {
38
+ actionSpec = runtime.getActionSpec();
39
+ observationSpec = runtime.getObservationSpec();
40
+ }
41
+
36
42
  async function ensureInitialized(context) {
37
43
  if (initialized) return;
38
44
  await controller.init?.(context);
@@ -47,9 +53,10 @@ export function createPaddockDriverControllerLoop({
47
53
  heldFramesRemaining = 0;
48
54
  policyStep = 0;
49
55
  runtimeStep = Number(result?.info?.step ?? 0);
56
+ observationCache = { ...(result?.observation ?? {}) };
50
57
  previousActions = {};
51
58
  lastError = null;
52
- await ensureSpecs();
59
+ await refreshSpecs();
53
60
  const context = buildContext({ resetDriverIds: controlledDrivers() });
54
61
  await ensureInitialized(context);
55
62
  await controller.reset?.(context);
@@ -71,6 +78,10 @@ export function createPaddockDriverControllerLoop({
71
78
  const resetDriverIds = Object.keys(placements);
72
79
  previousResult = result;
73
80
  result = runtime.resetDrivers(placements, resultOptions);
81
+ observationCache = {
82
+ ...observationCache,
83
+ ...(result?.observation ?? {}),
84
+ };
74
85
  heldActions = null;
75
86
  heldFramesRemaining = 0;
76
87
  lastError = null;
@@ -106,6 +117,7 @@ export function createPaddockDriverControllerLoop({
106
117
  const previousActionsForStep = previousActions;
107
118
  previousResult = result;
108
119
  result = runtime.step(actions);
120
+ observationCache = { ...(result?.observation ?? {}) };
109
121
  heldFramesRemaining -= 1;
110
122
  runtimeStep = Number(result?.info?.step ?? runtimeStep + 1);
111
123
  previousActions = actions;
@@ -167,7 +179,10 @@ export function createPaddockDriverControllerLoop({
167
179
 
168
180
  function buildContext(extra = {}) {
169
181
  const drivers = controlledDrivers();
170
- const observation = result?.observation ?? runtime.getObservation?.() ?? {};
182
+ const observation = {
183
+ ...observationCache,
184
+ ...(result?.observation ?? runtime.getObservation?.() ?? {}),
185
+ };
171
186
  return {
172
187
  runtime,
173
188
  mode,
@@ -45,8 +45,9 @@ export function buildDriverEpisodeInfo(episodeState, options, episode) {
45
45
  return Object.fromEntries(options.controlledDrivers.map((driverId) => {
46
46
  const state = ensureDriverEpisodeState(episodeState, driverId);
47
47
  const maxStepTruncated = state.episodeStep >= options.episode.maxSteps;
48
+ const aggregateTruncated = episode.truncated && episode.endReason !== 'all-drivers-ended';
48
49
  const terminated = Boolean(state.terminated || episode.terminated);
49
- const truncated = Boolean(state.truncated || maxStepTruncated || episode.truncated);
50
+ const truncated = Boolean(state.truncated || maxStepTruncated || aggregateTruncated);
50
51
  const endReason = state.endReason ??
51
52
  (terminated ? episode.endReason : null) ??
52
53
  (maxStepTruncated ? 'max-steps' : null) ??
@@ -67,13 +68,21 @@ export function evaluateEpisode(snapshot, options, episodeState, controlledDrive
67
68
  }
68
69
  let allTerminated = controlledDrivers.length > 0;
69
70
  let allTruncated = controlledDrivers.length > 0;
71
+ let allEnded = controlledDrivers.length > 0;
72
+ let anyTruncated = false;
70
73
  let firstTerminatedReason = null;
71
74
  for (const driverId of controlledDrivers) {
72
75
  const state = ensureDriverEpisodeState(episodeState, driverId);
76
+ if (!state.terminated && state.episodeStep >= options.episode.maxSteps) {
77
+ state.truncated = true;
78
+ state.endReason ??= 'max-steps';
79
+ }
73
80
  if (!state.terminated) allTerminated = false;
74
81
  else firstTerminatedReason ??= state.endReason;
75
82
  if (!state.truncated && state.episodeStep < options.episode.maxSteps) allTruncated = false;
76
- if (!allTerminated && !allTruncated) break;
83
+ if (state.truncated || state.episodeStep >= options.episode.maxSteps) anyTruncated = true;
84
+ if (!state.terminated && !state.truncated && state.episodeStep < options.episode.maxSteps) allEnded = false;
85
+ if (!allTerminated && !allTruncated && !allEnded) break;
77
86
  }
78
87
  if (allTerminated) {
79
88
  return { terminated: true, truncated: false, endReason: controlledDrivers.length === 1 ? firstTerminatedReason : 'all-drivers-terminated' };
@@ -81,6 +90,9 @@ export function evaluateEpisode(snapshot, options, episodeState, controlledDrive
81
90
  if (allTruncated) {
82
91
  return { terminated: false, truncated: true, endReason: 'max-steps' };
83
92
  }
93
+ if (allEnded) {
94
+ return { terminated: false, truncated: anyTruncated, endReason: 'all-drivers-ended' };
95
+ }
84
96
  return { terminated: false, truncated: false, endReason: null };
85
97
  }
86
98
 
@@ -364,6 +364,13 @@ export interface PaddockTrackLookaheadObservation {
364
364
  headingDeltaRadians: number;
365
365
  }
366
366
 
367
+ export interface PaddockAppliedControlsObservation {
368
+ steering: number;
369
+ steeringRadians: number;
370
+ throttle: number;
371
+ brake: number;
372
+ }
373
+
367
374
  export interface PaddockDriverObservationObject {
368
375
  profile: string;
369
376
  self: {
@@ -375,6 +382,7 @@ export interface PaddockDriverObservationObject {
375
382
  steeringAngleRadians: number;
376
383
  throttle: number;
377
384
  brake: number;
385
+ appliedControls: PaddockAppliedControlsObservation | null;
378
386
  lap: number;
379
387
  completedLaps: number;
380
388
  lapProgressMeters: number;
@@ -413,7 +421,7 @@ export interface PaddockDriverObservationObject {
413
421
  };
414
422
  contactPatches: PaddockContactPatchObservation[];
415
423
  race: {
416
- position: number;
424
+ position: number | null;
417
425
  totalCars: number;
418
426
  raceMode: string;
419
427
  pitLaneOpen: boolean;
@@ -590,12 +598,18 @@ export interface PaddockActionSpec {
590
598
  }
591
599
 
592
600
  export interface PaddockObservationSpec {
593
- version: 2 | 4;
601
+ version: 3 | 6;
594
602
  controlledDrivers: string[];
595
603
  object: Record<string, unknown>;
596
604
  vector: {
597
605
  schema: PaddockObservationSchemaEntry[];
598
606
  };
607
+ perDriver: Record<string, {
608
+ object: Record<string, unknown>;
609
+ vector: {
610
+ schema: PaddockObservationSchemaEntry[];
611
+ };
612
+ }>;
599
613
  }
600
614
 
601
615
  export interface PaddockRolloutTransition {
@@ -0,0 +1,200 @@
1
+ const MODEL_RAY_SURFACE_CHANNELS = Object.freeze(['kerb', 'illegalSurface']);
2
+
3
+ export function buildObservationVector(source, sensors, { includeSchema = true, vectorType = 'array' } = {}) {
4
+ const includePhysicalDriverSenses = source.profile === 'physical-driver';
5
+ const schema = includeSchema ? [
6
+ { name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
7
+ { name: 'self.speedMetersPerSecond', unit: 'm/s', scale: 'fixed:120' },
8
+ { name: 'self.steeringAngleRadians', unit: 'rad', scale: 'fixed:pi' },
9
+ { name: 'self.throttle', scale: '0..1' },
10
+ { name: 'self.brake', scale: '0..1' },
11
+ { name: 'self.lateralG', scale: 'fixed:8g' },
12
+ { name: 'self.longitudinalG', scale: 'fixed:6g' },
13
+ { name: 'self.gripUsage', scale: '0..2' },
14
+ { name: 'self.slipAngleRadians', unit: 'rad', scale: 'fixed:pi' },
15
+ { name: 'self.tractionLimited', scale: 'boolean' },
16
+ { name: 'self.lapProgressRatio', scale: '0..1' },
17
+ { name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
18
+ { name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
19
+ { name: 'self.onTrack', scale: 'boolean' },
20
+ { name: 'self.inPitLane', scale: 'boolean' },
21
+ { name: 'self.tireEnergy', scale: '0..100' },
22
+ { name: 'self.pitIntent', scale: '0..2' },
23
+ { name: 'self.pitStopActive', scale: 'boolean' },
24
+ { name: 'race.positionNormalized', scale: '0..1' },
25
+ { name: 'race.raceModeGreen', scale: 'boolean' },
26
+ { name: 'race.raceModeSafetyCar', scale: 'boolean' },
27
+ { name: 'race.redFlag', scale: 'boolean' },
28
+ { name: 'race.pitLaneOpen', scale: 'boolean' },
29
+ { name: 'track.curvature', scale: 'track-curvature' },
30
+ ] : null;
31
+ const vector = [
32
+ source.self.speedKph / 400,
33
+ source.self.speedMetersPerSecond / 120,
34
+ source.self.steeringAngleRadians / Math.PI,
35
+ source.self.throttle,
36
+ source.self.brake,
37
+ source.self.lateralG / 8,
38
+ source.self.longitudinalG / 6,
39
+ source.self.gripUsage / 2,
40
+ source.self.slipAngleRadians / Math.PI,
41
+ source.self.tractionLimited ? 1 : 0,
42
+ normalizeLapProgress(source),
43
+ source.self.trackOffsetMeters,
44
+ source.self.trackHeadingErrorRadians / Math.PI,
45
+ source.self.onTrack ? 1 : 0,
46
+ source.self.inPitLane ? 1 : 0,
47
+ (source.self.tireEnergy ?? 0) / 100,
48
+ (source.self.pitIntent ?? 0) / 2,
49
+ source.self.pitStopStatus && source.self.pitStopStatus !== 'pending' && source.self.pitStopStatus !== 'completed' ? 1 : 0,
50
+ normalizeRacePosition(source),
51
+ source.race.raceMode === 'green' ? 1 : 0,
52
+ source.race.raceMode === 'safety-car' ? 1 : 0,
53
+ source.race.redFlag ? 1 : 0,
54
+ source.race.pitLaneOpen ? 1 : 0,
55
+ source.track.curvature ?? 0,
56
+ ];
57
+ if (includePhysicalDriverSenses) {
58
+ pushSchema(schema,
59
+ { name: 'self.yawRateRadiansPerSecond', unit: 'rad/s', scale: 'fixed:pi' },
60
+ { name: 'trackRelation.leftBoundaryMeters', unit: 'm', scale: 'fixed:meters' },
61
+ { name: 'trackRelation.rightBoundaryMeters', unit: 'm', scale: 'fixed:meters' },
62
+ { name: 'trackRelation.legalWidthMeters', unit: 'm', scale: 'fixed:meters' },
63
+ );
64
+ vector.push(
65
+ source.self.yawRateRadiansPerSecond / Math.PI,
66
+ source.trackRelation.leftBoundaryMeters,
67
+ source.trackRelation.rightBoundaryMeters,
68
+ source.trackRelation.legalWidthMeters,
69
+ );
70
+ source.contactPatches.forEach((patch, index) => {
71
+ pushSchema(schema,
72
+ { name: `contactPatches[${index}].present`, scale: 'boolean' },
73
+ { name: `contactPatches[${index}].surfaceCode`, scale: 'surface-code' },
74
+ { name: `contactPatches[${index}].onLegalSurface`, scale: 'boolean' },
75
+ { name: `contactPatches[${index}].signedOffsetMeters`, unit: 'm', scale: 'fixed:meters' },
76
+ { name: `contactPatches[${index}].crossTrackErrorMeters`, unit: 'm', scale: 'fixed:meters' },
77
+ );
78
+ vector.push(
79
+ patch.present ? 1 : 0,
80
+ patch.surfaceCode / 5,
81
+ patch.onLegalSurface ? 1 : 0,
82
+ patch.signedOffsetMeters,
83
+ patch.crossTrackErrorMeters,
84
+ );
85
+ });
86
+ }
87
+ source.track.lookahead.forEach((sample, index) => {
88
+ pushSchema(schema,
89
+ { name: `track.lookahead[${index}].curvature`, scale: 'track-curvature' },
90
+ { name: `track.lookahead[${index}].headingDeltaRadians`, unit: 'rad', scale: 'fixed:pi' },
91
+ );
92
+ vector.push(sample.curvature ?? 0, (sample.headingDeltaRadians ?? 0) / Math.PI);
93
+ });
94
+ source.rays.forEach((ray, index) => {
95
+ pushSchema(schema,
96
+ { name: `rays[${index}].track.distanceRatio`, scale: '0..1' },
97
+ { name: `rays[${index}].track.hit`, scale: 'boolean' },
98
+ { name: `rays[${index}].track.kindExit`, scale: 'boolean' },
99
+ { name: `rays[${index}].track.kindEntry`, scale: 'boolean' },
100
+ { name: `rays[${index}].car.distanceRatio`, scale: '0..1' },
101
+ { name: `rays[${index}].car.hit`, scale: 'boolean' },
102
+ { name: `rays[${index}].car.relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
103
+ { name: `rays[${index}].car.targetTypeReplayGhost`, scale: 'boolean' },
104
+ );
105
+ vector.push(
106
+ ratio(ray.track.distanceMeters, ray.lengthMeters),
107
+ ray.track.hit ? 1 : 0,
108
+ ray.track.kind === 'exit' ? 1 : 0,
109
+ ray.track.kind === 'entry' ? 1 : 0,
110
+ ratio(ray.car.distanceMeters, ray.lengthMeters),
111
+ ray.car.hit ? 1 : 0,
112
+ ray.car.relativeSpeedKph / 200,
113
+ ray.car.targetType === 'replayGhost' ? 1 : 0,
114
+ );
115
+ MODEL_RAY_SURFACE_CHANNELS.forEach((channel) => {
116
+ if (sensors.rays.channels?.includes?.(channel)) {
117
+ pushSchema(schema,
118
+ { name: `rays[${index}].${channel}.distanceRatio`, scale: '0..1' },
119
+ { name: `rays[${index}].${channel}.hit`, scale: 'boolean' },
120
+ );
121
+ vector.push(
122
+ ratio(ray[channel]?.distanceMeters ?? ray.lengthMeters, ray.lengthMeters),
123
+ ray[channel]?.hit ? 1 : 0,
124
+ );
125
+ }
126
+ });
127
+ });
128
+ const nearbyLimit = sensors.nearbyCars.enabled ? (sensors.nearbyCars.maxCars ?? source.nearbyCars.length) : 0;
129
+ const nearbyRadius = sensors.nearbyCars.radiusMeters ?? 150;
130
+ for (let index = 0; index < nearbyLimit; index += 1) {
131
+ const nearby = source.nearbyCars[index] ?? null;
132
+ pushSchema(schema,
133
+ { name: `nearbyCars[${index}].present`, scale: 'boolean' },
134
+ { name: `nearbyCars[${index}].relativeForwardRatio`, scale: '-1..1' },
135
+ { name: `nearbyCars[${index}].relativeRightRatio`, scale: '-1..1' },
136
+ { name: `nearbyCars[${index}].relativeDistanceRatio`, scale: '0..1' },
137
+ { name: `nearbyCars[${index}].relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
138
+ { name: `nearbyCars[${index}].relativeHeadingRadians`, unit: 'rad', scale: 'fixed:pi' },
139
+ { name: `nearbyCars[${index}].ahead`, scale: 'boolean' },
140
+ { name: `nearbyCars[${index}].sameLap`, scale: 'boolean' },
141
+ { name: `nearbyCars[${index}].entityTypeReplayGhost`, scale: 'boolean' },
142
+ );
143
+ vector.push(
144
+ nearby ? 1 : 0,
145
+ clampRatio((nearby?.relativeForwardMeters ?? 0) / nearbyRadius),
146
+ clampRatio((nearby?.relativeRightMeters ?? 0) / nearbyRadius),
147
+ ratio(nearby?.relativeDistanceMeters ?? nearbyRadius, nearbyRadius),
148
+ (nearby?.relativeSpeedKph ?? 0) / 200,
149
+ (nearby?.relativeHeadingRadians ?? 0) / Math.PI,
150
+ nearby?.ahead ? 1 : 0,
151
+ nearby?.sameLap ? 1 : 0,
152
+ nearby?.entityType === 'replayGhost' ? 1 : 0,
153
+ );
154
+ if (includePhysicalDriverSenses) {
155
+ pushSchema(schema,
156
+ { name: `nearbyCars[${index}].behind`, scale: 'boolean' },
157
+ { name: `nearbyCars[${index}].closingRateMetersPerSecond`, unit: 'm/s', scale: 'fixed:100' },
158
+ { name: `nearbyCars[${index}].timeToContactSeconds`, unit: 's', scale: 'fixed:10' },
159
+ { name: `nearbyCars[${index}].leftOverlap`, scale: 'boolean' },
160
+ { name: `nearbyCars[${index}].rightOverlap`, scale: 'boolean' },
161
+ );
162
+ vector.push(
163
+ nearby?.behind ? 1 : 0,
164
+ (nearby?.closingRateMetersPerSecond ?? 0) / 100,
165
+ ratio(nearby?.timeToContactSeconds ?? 10, 10),
166
+ nearby?.leftOverlap ? 1 : 0,
167
+ nearby?.rightOverlap ? 1 : 0,
168
+ );
169
+ }
170
+ }
171
+ return { vector: finalizeVector(vector, vectorType), schema: schema ?? [] };
172
+ }
173
+
174
+ function finalizeVector(vector, vectorType = 'array') {
175
+ return vectorType === 'float32' ? Float32Array.from(vector) : vector;
176
+ }
177
+
178
+ function pushSchema(schema, ...entries) {
179
+ if (schema) schema.push(...entries);
180
+ }
181
+
182
+ function normalizeLapProgress(source) {
183
+ return ratio(source.self.lapProgressMeters, source.track.lengthMeters || 1);
184
+ }
185
+
186
+ function normalizeRacePosition(source) {
187
+ const totalCars = Number(source.race.totalCars);
188
+ const position = Number(source.race.position);
189
+ if (!Number.isFinite(totalCars) || totalCars <= 1 || !Number.isFinite(position)) return 0;
190
+ return ratio(position - 1, totalCars - 1);
191
+ }
192
+
193
+ function ratio(value, max) {
194
+ const finite = Number.isFinite(value) ? value : max;
195
+ return Math.max(0, Math.min(1, finite / Math.max(1e-9, max)));
196
+ }
197
+
198
+ function clampRatio(value) {
199
+ return Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
200
+ }