@inventure71/paddockjs 0.2.0 → 0.3.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.
@@ -45,7 +45,6 @@ export function querySimulatorDom(root) {
45
45
  telemetryDrawerWorkbench: root.querySelector('[data-race-telemetry-drawer]'),
46
46
  telemetryDrawer: root.querySelector('[data-telemetry-drawer]'),
47
47
  telemetryDrawerToggle: root.querySelector('[data-telemetry-drawer-toggle]'),
48
- telemetryDrawerClose: root.querySelector('[data-telemetry-drawer-close]'),
49
48
  raceDataPanel: root.querySelector('[data-race-data-panel]'),
50
49
  raceDataKicker: root.querySelector('[data-race-data-kicker]'),
51
50
  raceDataTitle: root.querySelector('[data-race-data-title]'),
@@ -25,6 +25,7 @@ export const DEFAULT_F1_SIMULATOR_OPTIONS = {
25
25
  sectorTimes: true,
26
26
  },
27
27
  showRaceDataPanel: true,
28
+ raceDataTelemetryDetail: false,
28
29
  raceDataBanners: {
29
30
  initial: 'project',
30
31
  enabled: ['project', 'radio'],
@@ -109,6 +110,8 @@ export const PADDOCK_THEME_CSS_VARIABLES = {
109
110
  raceViewMinHeight: '--paddock-race-view-min-height',
110
111
  };
111
112
 
113
+ const SUPPORTED_CAMERA_MODES = new Set(['overview', 'leader', 'selected', 'show-all']);
114
+
112
115
  export function resolveF1SimulatorOptions(options = {}) {
113
116
  const presetName = Object.hasOwn(PADDOCK_SIMULATOR_PRESETS, options.preset)
114
117
  ? options.preset
@@ -140,6 +143,10 @@ export function resolveF1SimulatorOptions(options = {}) {
140
143
  if (!['auto', 'custom'].includes(ui.raceDataBannerSize)) {
141
144
  ui.raceDataBannerSize = DEFAULT_F1_SIMULATOR_OPTIONS.ui.raceDataBannerSize;
142
145
  }
146
+ ui.raceDataTelemetryDetail = Boolean(ui.raceDataTelemetryDetail);
147
+ const initialCameraMode = SUPPORTED_CAMERA_MODES.has(options.initialCameraMode)
148
+ ? options.initialCameraMode
149
+ : DEFAULT_F1_SIMULATOR_OPTIONS.initialCameraMode;
143
150
  const mergedOptions = {
144
151
  ...DEFAULT_F1_SIMULATOR_OPTIONS,
145
152
  ...preset,
@@ -159,6 +166,7 @@ export function resolveF1SimulatorOptions(options = {}) {
159
166
  ...preset,
160
167
  ...options,
161
168
  preset: presetName ?? options.preset,
169
+ initialCameraMode,
162
170
  ui,
163
171
  theme,
164
172
  drivers,
@@ -136,6 +136,7 @@ function pickUniqueTimingCode(source, usedCodes) {
136
136
  function assertUniqueDriverNumbers(entries) {
137
137
  const seen = new Set();
138
138
  entries.forEach((entry) => {
139
+ if (entry.driverNumber == null) return;
139
140
  if (seen.has(entry.driverNumber)) {
140
141
  throw new Error(`Duplicate championship driver number: ${entry.driverNumber}`);
141
142
  }
@@ -143,6 +144,17 @@ function assertUniqueDriverNumbers(entries) {
143
144
  });
144
145
  }
145
146
 
147
+ function assertUniqueEntryDriverIds(entries) {
148
+ const seen = new Set();
149
+ entries.forEach((entry) => {
150
+ if (entry.driverId == null) return;
151
+ if (seen.has(entry.driverId)) {
152
+ throw new Error(`Duplicate championship entry driver id: ${entry.driverId}`);
153
+ }
154
+ seen.add(entry.driverId);
155
+ });
156
+ }
157
+
146
158
  function buildDriverConstructorArgs(entry) {
147
159
  const driver = entry.driver instanceof DriverData
148
160
  ? entry.driver
@@ -181,6 +193,7 @@ function normalizeTeam(team, driver, timingCode) {
181
193
  }
182
194
 
183
195
  export function buildChampionshipDriverGrid(drivers = DEMO_PROJECT_DRIVERS, entries = CHAMPIONSHIP_DRIVER_ENTRIES) {
196
+ assertUniqueEntryDriverIds(entries);
184
197
  assertUniqueDriverNumbers(entries);
185
198
 
186
199
  const entryByDriverId = new Map(entries.map((entry) => [entry.driverId, entry]));
@@ -1,10 +1,11 @@
1
1
  import { buildChampionshipDriverGrid } from './championship.js';
2
2
 
3
- export function normalizeSimulatorDrivers(drivers, { entries } = {}) {
3
+ export function normalizeSimulatorDrivers(drivers, { entries, caller = 'mountF1Simulator' } = {}) {
4
4
  if (!Array.isArray(drivers) || drivers.length === 0) {
5
- throw new Error('mountF1Simulator requires a non-empty drivers array.');
5
+ throw new Error(`${caller} requires a non-empty drivers array.`);
6
6
  }
7
7
 
8
+ const seenDriverIds = new Set();
8
9
  drivers.forEach((driver, index) => {
9
10
  if (!driver || typeof driver !== 'object') {
10
11
  throw new Error(`Invalid simulator driver at index ${index}.`);
@@ -12,6 +13,10 @@ export function normalizeSimulatorDrivers(drivers, { entries } = {}) {
12
13
  if (!driver.id) {
13
14
  throw new Error(`Simulator driver at index ${index} is missing an id.`);
14
15
  }
16
+ if (seenDriverIds.has(driver.id)) {
17
+ throw new Error(`Duplicate simulator driver id: ${driver.id}`);
18
+ }
19
+ seenDriverIds.add(driver.id);
15
20
  if (!driver.name) {
16
21
  throw new Error(`Simulator driver "${driver.id}" is missing a name.`);
17
22
  }
@@ -0,0 +1,45 @@
1
+ import { clamp } from '../simulation/simMath.js';
2
+ import { VEHICLE_LIMITS } from '../simulation/vehiclePhysics.js';
3
+
4
+ export function resolveActionMap(actions = {}, controlledDrivers = [], { policy = 'strict' } = {}) {
5
+ const errors = [];
6
+ const controlsByDriver = {};
7
+
8
+ controlledDrivers.forEach((driverId) => {
9
+ const action = actions?.[driverId];
10
+ if (!action || typeof action !== 'object') {
11
+ handleActionError(`Missing action for controlled driver: ${driverId}`, policy, errors);
12
+ return;
13
+ }
14
+ const normalized = normalizeAction(action, driverId, policy, errors);
15
+ if (normalized) controlsByDriver[driverId] = normalized;
16
+ });
17
+
18
+ return { controlsByDriver, errors };
19
+ }
20
+
21
+ export function normalizeAction(action, driverId = 'unknown', policy = 'strict', errors = []) {
22
+ const steering = finiteActionValue(action.steering, `Invalid steering action for controlled driver: ${driverId}`, policy, errors);
23
+ const throttle = finiteActionValue(action.throttle, `Invalid throttle action for controlled driver: ${driverId}`, policy, errors);
24
+ const brake = finiteActionValue(action.brake, `Invalid brake action for controlled driver: ${driverId}`, policy, errors);
25
+
26
+ if (steering == null || throttle == null || brake == null) return null;
27
+
28
+ return {
29
+ steering: clamp(steering, -1, 1) * VEHICLE_LIMITS.maxSteer,
30
+ throttle: clamp(throttle, 0, 1),
31
+ brake: clamp(brake, 0, 1),
32
+ };
33
+ }
34
+
35
+ function finiteActionValue(value, message, policy, errors) {
36
+ const number = Number(value ?? 0);
37
+ if (Number.isFinite(number)) return number;
38
+ handleActionError(message, policy, errors);
39
+ return null;
40
+ }
41
+
42
+ function handleActionError(message, policy, errors) {
43
+ if (policy === 'strict') throw new Error(message);
44
+ errors.push(message);
45
+ }
@@ -0,0 +1,17 @@
1
+ export function createEpisodeState() {
2
+ return {
3
+ step: 0,
4
+ previousSnapshot: null,
5
+ lastResult: null,
6
+ };
7
+ }
8
+
9
+ export function evaluateEpisode(snapshot, options, episodeState) {
10
+ if (snapshot.raceControl.finished && options.episode.endOnRaceFinish) {
11
+ return { terminated: true, truncated: false, endReason: 'race-finish' };
12
+ }
13
+ if (episodeState.step >= options.episode.maxSteps) {
14
+ return { terminated: false, truncated: true, endReason: 'max-steps' };
15
+ }
16
+ return { terminated: false, truncated: false, endReason: null };
17
+ }
@@ -0,0 +1,17 @@
1
+ export function collectStepEvents(events = []) {
2
+ return events.map((event) => normalizeEnvironmentEvent(event));
3
+ }
4
+
5
+ function normalizeEnvironmentEvent(event) {
6
+ if (event.type === 'contact') {
7
+ return {
8
+ ...event,
9
+ type: 'collision',
10
+ driverIds: [event.carId, event.otherCarId].filter(Boolean),
11
+ primaryDriverId: event.carId ?? null,
12
+ otherDriverId: event.otherCarId ?? null,
13
+ severity: event.severity ?? null,
14
+ };
15
+ }
16
+ return { ...event };
17
+ }
@@ -0,0 +1,241 @@
1
+ export type TireCompound = 'S' | 'M' | 'H';
2
+
3
+ export interface TeamData {
4
+ id?: string;
5
+ name?: string;
6
+ color?: string;
7
+ icon?: string;
8
+ }
9
+
10
+ export interface SimulatorDriver {
11
+ id: string;
12
+ name: string;
13
+ color: string;
14
+ link?: string;
15
+ icon?: string;
16
+ code?: string;
17
+ timingName?: string;
18
+ tire?: TireCompound;
19
+ raceData?: string[];
20
+ customFields?: Array<{ label: string; value: string }> | Record<string, string>;
21
+ team?: TeamData | null;
22
+ driverNumber?: number;
23
+ }
24
+
25
+ export interface ChampionshipEntryBlueprint {
26
+ driverId: string;
27
+ driverNumber?: number;
28
+ timingName?: string;
29
+ driver?: unknown;
30
+ vehicle?: unknown;
31
+ team?: TeamData;
32
+ }
33
+
34
+ export interface RaceEvent {
35
+ type: string;
36
+ at?: number;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface RaceSnapshot {
41
+ time: number;
42
+ totalLaps: number;
43
+ raceControl: {
44
+ mode: string;
45
+ finished: boolean;
46
+ [key: string]: unknown;
47
+ };
48
+ track: Record<string, unknown>;
49
+ cars: Array<Record<string, unknown>>;
50
+ events: RaceEvent[];
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ export interface PaddockAction {
55
+ steering: number;
56
+ throttle: number;
57
+ brake: number;
58
+ }
59
+
60
+ export type PaddockActionMap = Record<string, PaddockAction>;
61
+
62
+ export interface PaddockSensorRayResult {
63
+ angleDegrees: number;
64
+ angleRadians: number;
65
+ lengthMeters: number;
66
+ track: {
67
+ hit: boolean;
68
+ distanceMeters: number;
69
+ kind: 'exit' | 'entry' | null;
70
+ };
71
+ car: {
72
+ hit: boolean;
73
+ distanceMeters: number;
74
+ driverId: string | null;
75
+ relativeSpeedKph: number;
76
+ };
77
+ }
78
+
79
+ export interface PaddockNearbyCarObservation {
80
+ id: string;
81
+ relativeForwardMeters: number;
82
+ relativeRightMeters: number;
83
+ relativeDistanceMeters: number;
84
+ relativeSpeedKph: number;
85
+ relativeHeadingRadians: number;
86
+ ahead: boolean;
87
+ sameLap: boolean;
88
+ }
89
+
90
+ export interface PaddockDriverObservationObject {
91
+ self: {
92
+ id: string;
93
+ speedKph: number;
94
+ speedMetersPerSecond: number;
95
+ headingRadians: number;
96
+ steeringAngleRadians: number;
97
+ throttle: number;
98
+ brake: number;
99
+ lap: number;
100
+ completedLaps: number;
101
+ lapProgressMeters: number;
102
+ trackOffsetMeters: number;
103
+ trackHeadingErrorRadians: number;
104
+ onTrack: boolean;
105
+ surface: string;
106
+ tireEnergy: number | null;
107
+ };
108
+ race: {
109
+ position: number;
110
+ totalCars: number;
111
+ raceMode: string;
112
+ totalLaps: number;
113
+ };
114
+ rays: PaddockSensorRayResult[];
115
+ nearbyCars: PaddockNearbyCarObservation[];
116
+ events: RaceEvent[];
117
+ }
118
+
119
+ export interface PaddockObservationSchemaEntry {
120
+ name: string;
121
+ unit?: string;
122
+ scale?: string;
123
+ }
124
+
125
+ export interface PaddockDriverObservation {
126
+ object: PaddockDriverObservationObject;
127
+ vector: number[];
128
+ schema: PaddockObservationSchemaEntry[];
129
+ events: RaceEvent[];
130
+ }
131
+
132
+ export interface PaddockEnvironmentOptions {
133
+ drivers: SimulatorDriver[];
134
+ entries?: ChampionshipEntryBlueprint[];
135
+ controlledDrivers: string[];
136
+ seed?: number;
137
+ trackSeed?: number;
138
+ totalLaps?: number;
139
+ frameSkip?: number;
140
+ actionPolicy?: 'strict' | 'report';
141
+ scenario?: {
142
+ participants?: 'all' | 'controlled-only' | string[];
143
+ nonControlled?: 'ai';
144
+ };
145
+ sensors?: {
146
+ rays?: {
147
+ enabled?: boolean;
148
+ anglesDegrees?: number[];
149
+ lengthMeters?: number;
150
+ detectTrack?: boolean;
151
+ detectCars?: boolean;
152
+ };
153
+ nearbyCars?: {
154
+ enabled?: boolean;
155
+ maxCars?: number;
156
+ radiusMeters?: number;
157
+ };
158
+ };
159
+ sensorsByDriver?: Record<string, PaddockEnvironmentOptions['sensors']>;
160
+ episode?: {
161
+ maxSteps?: number;
162
+ endOnRaceFinish?: boolean;
163
+ };
164
+ reward?: (payload: PaddockRewardContext) => number;
165
+ }
166
+
167
+ export interface PaddockRewardContext {
168
+ driverId: string;
169
+ previous: RaceSnapshot | null;
170
+ current: PaddockDriverObservation;
171
+ action: PaddockAction | undefined;
172
+ events: RaceEvent[];
173
+ state: { snapshot: RaceSnapshot };
174
+ }
175
+
176
+ export interface PaddockProgressRewardWeights {
177
+ progress: number;
178
+ speed: number;
179
+ offTrack: number;
180
+ collision: number;
181
+ steering: number;
182
+ brake: number;
183
+ }
184
+
185
+ export interface PaddockProgressRewardOptions {
186
+ weights?: Partial<PaddockProgressRewardWeights>;
187
+ }
188
+
189
+ export interface PaddockActionSpec {
190
+ version: 1;
191
+ controlledDrivers: string[];
192
+ action: {
193
+ type: 'continuous';
194
+ perDriver: {
195
+ steering: { min: -1; max: 1; unit: 'normalized' };
196
+ throttle: { min: 0; max: 1; unit: 'normalized' };
197
+ brake: { min: 0; max: 1; unit: 'normalized' };
198
+ };
199
+ };
200
+ }
201
+
202
+ export interface PaddockObservationSpec {
203
+ version: 1;
204
+ controlledDrivers: string[];
205
+ object: Record<string, unknown>;
206
+ vector: {
207
+ schema: PaddockObservationSchemaEntry[];
208
+ };
209
+ }
210
+
211
+ export interface PaddockEnvironmentResult {
212
+ observation: Record<string, PaddockDriverObservation>;
213
+ reward: null | Record<string, number>;
214
+ terminated: boolean;
215
+ truncated: boolean;
216
+ done: boolean;
217
+ events: RaceEvent[];
218
+ state: { snapshot: RaceSnapshot };
219
+ info: {
220
+ step: number;
221
+ elapsedSeconds: number;
222
+ seed: number;
223
+ trackSeed: number;
224
+ controlledDrivers: string[];
225
+ actionErrors: string[];
226
+ endReason: string | null;
227
+ };
228
+ }
229
+
230
+ export interface PaddockEnvironment {
231
+ reset(options?: Partial<PaddockEnvironmentOptions>): PaddockEnvironmentResult;
232
+ step(actions: PaddockActionMap): PaddockEnvironmentResult;
233
+ getObservation(): PaddockEnvironmentResult['observation'];
234
+ getState(): PaddockEnvironmentResult['state'];
235
+ getActionSpec(): PaddockActionSpec;
236
+ getObservationSpec(): PaddockObservationSpec;
237
+ destroy(): void;
238
+ }
239
+
240
+ export function createPaddockEnvironment(options: PaddockEnvironmentOptions): PaddockEnvironment;
241
+ export function createProgressReward(options?: PaddockProgressRewardOptions): (context: PaddockRewardContext) => number;
@@ -0,0 +1,2 @@
1
+ export { createPaddockEnvironment } from './runtime.js';
2
+ export { createProgressReward } from './rewards.js';
@@ -0,0 +1,112 @@
1
+ import { simSpeedToMetersPerSecond, simUnitsToMeters } from '../simulation/units.js';
2
+ import { buildNearbyCars, buildRaySensors } from './sensors.js';
3
+
4
+ export function buildEnvironmentObservation({ snapshot, options, events = [] }) {
5
+ return Object.fromEntries(options.controlledDrivers.map((driverId) => {
6
+ const car = snapshot.cars.find((entry) => entry.id === driverId);
7
+ if (!car) return [driverId, emptyObservation(driverId)];
8
+
9
+ const driverEvents = events.filter((event) =>
10
+ event.driverId === driverId ||
11
+ event.carId === driverId ||
12
+ event.otherCarId === driverId ||
13
+ event.driverIds?.includes?.(driverId));
14
+ const object = buildDriverObservationObject(car, snapshot, options, driverEvents);
15
+ const { vector, schema } = buildDriverVector(object);
16
+ return [driverId, { object, vector, schema, events: driverEvents }];
17
+ }));
18
+ }
19
+
20
+ function buildDriverObservationObject(car, snapshot, options, events) {
21
+ const sensors = effectiveSensorOptions(options, car.id);
22
+ return {
23
+ self: {
24
+ id: car.id,
25
+ speedKph: car.speedKph,
26
+ speedMetersPerSecond: simSpeedToMetersPerSecond(car.speed ?? 0),
27
+ headingRadians: car.heading,
28
+ steeringAngleRadians: car.steeringAngle ?? 0,
29
+ throttle: car.throttle ?? 0,
30
+ brake: car.brake ?? 0,
31
+ lap: car.lap,
32
+ completedLaps: car.lapTelemetry?.completedLaps ?? 0,
33
+ lapProgressMeters: simUnitsToMeters(car.progress ?? 0),
34
+ trackOffsetMeters: simUnitsToMeters(car.signedOffset ?? 0),
35
+ trackHeadingErrorRadians: car.trackHeadingError ?? 0,
36
+ onTrack: (car.surface ?? 'track') === 'track',
37
+ surface: car.surface ?? 'track',
38
+ tireEnergy: car.tireEnergy ?? null,
39
+ },
40
+ race: {
41
+ position: car.rank,
42
+ totalCars: snapshot.cars.length,
43
+ raceMode: snapshot.raceControl.mode,
44
+ totalLaps: snapshot.totalLaps,
45
+ },
46
+ rays: sensors.rays.enabled ? buildRaySensors(car, snapshot, sensors.rays) : [],
47
+ nearbyCars: sensors.nearbyCars.enabled ? buildNearbyCars(car, snapshot, sensors.nearbyCars) : [],
48
+ events,
49
+ };
50
+ }
51
+
52
+ function buildDriverVector(object) {
53
+ const schema = [
54
+ { name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
55
+ { name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
56
+ { name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
57
+ { name: 'self.onTrack', scale: 'boolean' },
58
+ { name: 'race.position', scale: 'fixed:field-position' },
59
+ ];
60
+ const vector = [
61
+ object.self.speedKph / 400,
62
+ object.self.trackOffsetMeters,
63
+ object.self.trackHeadingErrorRadians / Math.PI,
64
+ object.self.onTrack ? 1 : 0,
65
+ object.race.position,
66
+ ];
67
+ return { vector, schema };
68
+ }
69
+
70
+ function effectiveSensorOptions(options, driverId) {
71
+ return {
72
+ rays: {
73
+ ...options.sensors.rays,
74
+ ...(options.sensorsByDriver?.[driverId]?.rays ?? {}),
75
+ },
76
+ nearbyCars: {
77
+ ...options.sensors.nearbyCars,
78
+ ...(options.sensorsByDriver?.[driverId]?.nearbyCars ?? {}),
79
+ },
80
+ };
81
+ }
82
+
83
+ function emptyObservation(driverId) {
84
+ return {
85
+ object: {
86
+ self: {
87
+ id: driverId,
88
+ speedKph: 0,
89
+ speedMetersPerSecond: 0,
90
+ headingRadians: 0,
91
+ steeringAngleRadians: 0,
92
+ throttle: 0,
93
+ brake: 0,
94
+ lap: 0,
95
+ completedLaps: 0,
96
+ lapProgressMeters: 0,
97
+ trackOffsetMeters: 0,
98
+ trackHeadingErrorRadians: 0,
99
+ onTrack: false,
100
+ surface: 'missing',
101
+ tireEnergy: null,
102
+ },
103
+ race: { position: 0, totalCars: 0, raceMode: 'missing', totalLaps: 0 },
104
+ rays: [],
105
+ nearbyCars: [],
106
+ events: [],
107
+ },
108
+ vector: [],
109
+ schema: [],
110
+ events: [],
111
+ };
112
+ }
@@ -0,0 +1,150 @@
1
+ import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../data/championship.js';
2
+ import { normalizeSimulatorDrivers } from '../data/normalizeDrivers.js';
3
+
4
+ const DEFAULT_SEED = 1971;
5
+ const DEFAULT_FRAME_SKIP = 1;
6
+ const DEFAULT_MAX_STEPS = 10000;
7
+
8
+ const DEFAULT_SENSOR_OPTIONS = {
9
+ rays: {
10
+ enabled: true,
11
+ anglesDegrees: [-135, -60, -20, 0, 20, 60, 135, 180],
12
+ lengthMeters: 120,
13
+ detectTrack: true,
14
+ detectCars: true,
15
+ },
16
+ nearbyCars: {
17
+ enabled: true,
18
+ maxCars: 6,
19
+ radiusMeters: 150,
20
+ },
21
+ };
22
+
23
+ export function resolveEnvironmentOptions(options = {}) {
24
+ if (!Array.isArray(options.controlledDrivers) || options.controlledDrivers.length === 0) {
25
+ throw new Error('PaddockJS environment controlledDrivers is required.');
26
+ }
27
+
28
+ const seed = normalizeSeed(options.seed, createGeneratedSeed);
29
+ const trackSeed = normalizeSeed(options.trackSeed, createGeneratedSeed);
30
+ const normalizedDrivers = normalizeSimulatorDrivers(options.drivers, {
31
+ entries: options.entries ?? CHAMPIONSHIP_ENTRY_BLUEPRINTS,
32
+ caller: 'createPaddockEnvironment',
33
+ });
34
+ const resolved = {
35
+ ...options,
36
+ seed,
37
+ trackSeed,
38
+ totalLaps: options.totalLaps,
39
+ drivers: normalizedDrivers,
40
+ };
41
+ const driverIds = new Set(resolved.drivers.map((driver) => driver.id));
42
+ const controlledDrivers = [...new Set(options.controlledDrivers)];
43
+
44
+ controlledDrivers.forEach((driverId) => {
45
+ if (!driverIds.has(driverId)) {
46
+ throw new Error(`PaddockJS environment controlled driver does not exist: ${driverId}`);
47
+ }
48
+ });
49
+
50
+ const scenario = resolveScenario(options.scenario, controlledDrivers, driverIds);
51
+ const participants = resolveParticipants(scenario.participants, controlledDrivers, resolved.drivers);
52
+ const participantDrivers = resolved.drivers.filter((driver) => participants.has(driver.id));
53
+ const participantIds = new Set(participantDrivers.map((driver) => driver.id));
54
+
55
+ controlledDrivers.forEach((driverId) => {
56
+ if (!participantIds.has(driverId)) {
57
+ throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
58
+ }
59
+ });
60
+
61
+ return {
62
+ ...resolved,
63
+ drivers: participantDrivers,
64
+ controlledDrivers,
65
+ frameSkip: normalizePositiveInteger(options.frameSkip, DEFAULT_FRAME_SKIP, 'frameSkip'),
66
+ actionPolicy: options.actionPolicy === 'report' ? 'report' : 'strict',
67
+ scenario,
68
+ sensors: mergeSensorOptions(options.sensors),
69
+ sensorsByDriver: options.sensorsByDriver ?? {},
70
+ episode: {
71
+ maxSteps: normalizePositiveInteger(options.episode?.maxSteps, DEFAULT_MAX_STEPS, 'episode.maxSteps'),
72
+ endOnRaceFinish: options.episode?.endOnRaceFinish !== false,
73
+ },
74
+ reward: typeof options.reward === 'function' ? options.reward : null,
75
+ };
76
+ }
77
+
78
+ function normalizeSeed(value, fallbackFactory) {
79
+ if (value == null) return fallbackFactory();
80
+ const number = Number(value);
81
+ return Number.isFinite(number) ? number : DEFAULT_SEED;
82
+ }
83
+
84
+ function createGeneratedSeed() {
85
+ const values = new Uint32Array(1);
86
+ try {
87
+ globalThis.crypto?.getRandomValues?.(values);
88
+ } catch {
89
+ values[0] = 0;
90
+ }
91
+ return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
92
+ }
93
+
94
+ function resolveScenario(scenario = {}, controlledDrivers, driverIds) {
95
+ const participants = scenario.participants ?? 'all';
96
+ if (
97
+ participants !== 'all' &&
98
+ participants !== 'controlled-only' &&
99
+ !Array.isArray(participants)
100
+ ) {
101
+ throw new Error('PaddockJS environment scenario.participants must be "all", "controlled-only", or an array of driver ids.');
102
+ }
103
+ if (scenario.nonControlled != null && scenario.nonControlled !== 'ai') {
104
+ throw new Error('PaddockJS environment first slice only supports scenario.nonControlled: "ai".');
105
+ }
106
+ if (Array.isArray(participants)) {
107
+ participants.forEach((driverId) => {
108
+ if (!driverIds.has(driverId)) {
109
+ throw new Error(`PaddockJS environment scenario participant does not exist: ${driverId}`);
110
+ }
111
+ });
112
+ controlledDrivers.forEach((driverId) => {
113
+ if (!participants.includes(driverId)) {
114
+ throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
115
+ }
116
+ });
117
+ }
118
+ return {
119
+ participants,
120
+ nonControlled: 'ai',
121
+ };
122
+ }
123
+
124
+ function resolveParticipants(participants, controlledDrivers, drivers) {
125
+ if (participants === 'controlled-only') return new Set(controlledDrivers);
126
+ if (Array.isArray(participants)) return new Set(participants);
127
+ return new Set(drivers.map((driver) => driver.id));
128
+ }
129
+
130
+ function normalizePositiveInteger(value, fallback, label) {
131
+ if (value == null) return fallback;
132
+ const number = Math.floor(Number(value));
133
+ if (!Number.isFinite(number) || number < 1) {
134
+ throw new Error(`PaddockJS environment ${label} must be a positive integer.`);
135
+ }
136
+ return number;
137
+ }
138
+
139
+ function mergeSensorOptions(sensors = {}) {
140
+ return {
141
+ rays: {
142
+ ...DEFAULT_SENSOR_OPTIONS.rays,
143
+ ...(sensors.rays ?? {}),
144
+ },
145
+ nearbyCars: {
146
+ ...DEFAULT_SENSOR_OPTIONS.nearbyCars,
147
+ ...(sensors.nearbyCars ?? {}),
148
+ },
149
+ };
150
+ }