@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.
@@ -0,0 +1,69 @@
1
+ const DEFAULT_PROGRESS_REWARD_WEIGHTS = Object.freeze({
2
+ progress: 1,
3
+ speed: 0.01,
4
+ offTrack: -2,
5
+ collision: -10,
6
+ steering: -0.03,
7
+ brake: -0.02,
8
+ });
9
+
10
+ export function createProgressReward(options = {}) {
11
+ const weights = {
12
+ ...DEFAULT_PROGRESS_REWARD_WEIGHTS,
13
+ ...(options.weights ?? {}),
14
+ };
15
+
16
+ return function progressReward(context = {}) {
17
+ const driverId = context.driverId;
18
+ const previousCar = findSnapshotCar(context.previous, driverId);
19
+ const currentCar = findSnapshotCar(context.state?.snapshot, driverId);
20
+ const progressMeters = getProgressMeters(previousCar, currentCar);
21
+ const self = context.current?.object?.self ?? {};
22
+ const speedKph = Number(self.speedKph ?? 0);
23
+ const isOnTrack = self.onTrack !== false;
24
+ const collisionCount = countDriverCollisions(context.events, driverId);
25
+ const steering = Math.abs(Number(context.action?.steering ?? 0));
26
+ const brake = Math.abs(Number(context.action?.brake ?? 0));
27
+
28
+ return (
29
+ progressMeters * weights.progress +
30
+ (isOnTrack ? speedKph * weights.speed : 0) +
31
+ (isOnTrack ? 0 : weights.offTrack) +
32
+ collisionCount * weights.collision +
33
+ steering * weights.steering +
34
+ brake * weights.brake
35
+ );
36
+ };
37
+ }
38
+
39
+ function findSnapshotCar(snapshot, driverId) {
40
+ if (!snapshot || !driverId || !Array.isArray(snapshot.cars)) return null;
41
+ return snapshot.cars.find((car) => car?.id === driverId) ?? null;
42
+ }
43
+
44
+ function getProgressMeters(previousCar, currentCar) {
45
+ if (!previousCar || !currentCar) return 0;
46
+ const previous = Number(previousCar.distanceMeters ?? previousCar.raceDistanceMeters ?? 0);
47
+ const current = Number(currentCar.distanceMeters ?? currentCar.raceDistanceMeters ?? 0);
48
+ if (!Number.isFinite(previous) || !Number.isFinite(current)) return 0;
49
+ return current - previous;
50
+ }
51
+
52
+ function countDriverCollisions(events = [], driverId) {
53
+ if (!driverId || !Array.isArray(events)) return 0;
54
+ return events.filter((event) => isCollisionEvent(event) && eventIncludesDriver(event, driverId)).length;
55
+ }
56
+
57
+ function isCollisionEvent(event) {
58
+ return event?.type === 'collision' || event?.type === 'contact';
59
+ }
60
+
61
+ function eventIncludesDriver(event, driverId) {
62
+ if (event.driverId === driverId ||
63
+ event.carId === driverId ||
64
+ event.primaryDriverId === driverId ||
65
+ event.otherDriverId === driverId) {
66
+ return true;
67
+ }
68
+ return Array.isArray(event.driverIds) && event.driverIds.includes(driverId);
69
+ }
@@ -0,0 +1,140 @@
1
+ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
2
+ import { resolveActionMap } from './actions.js';
3
+ import { collectStepEvents } from './events.js';
4
+ import { createEpisodeState, evaluateEpisode } from './episode.js';
5
+ import { buildEnvironmentObservation } from './observations.js';
6
+ import { resolveEnvironmentOptions } from './options.js';
7
+ import { buildActionSpec, buildObservationSpec } from './specs.js';
8
+
9
+ export function createPaddockEnvironment(options = {}) {
10
+ let resolvedOptions = resolveEnvironmentOptions(options);
11
+ let sim = createRaceSimulation(resolvedOptions);
12
+ return createEnvironmentRuntime({
13
+ getSimulation: () => sim,
14
+ setSimulation(nextSim) {
15
+ sim = nextSim;
16
+ },
17
+ createSimulation(nextOptions) {
18
+ return createRaceSimulation(nextOptions);
19
+ },
20
+ getOptions: () => resolvedOptions,
21
+ setOptions(nextOptions) {
22
+ resolvedOptions = nextOptions;
23
+ },
24
+ afterReset() {},
25
+ afterStep() {},
26
+ });
27
+ }
28
+
29
+ export function createEnvironmentRuntime(host) {
30
+ const episodeState = createEpisodeState();
31
+
32
+ function reset(nextOptions = {}) {
33
+ const options = resolveEnvironmentOptions({
34
+ ...host.getOptions(),
35
+ ...nextOptions,
36
+ });
37
+ host.setOptions(options);
38
+ host.setSimulation(host.createSimulation(options));
39
+ episodeState.step = 0;
40
+ episodeState.previousSnapshot = null;
41
+ const result = buildResult({ host, episodeState, events: [], actionErrors: [] });
42
+ episodeState.lastResult = result;
43
+ host.afterReset(result);
44
+ return result;
45
+ }
46
+
47
+ function step(actions = {}) {
48
+ const options = host.getOptions();
49
+ const sim = host.getSimulation();
50
+ const { controlsByDriver, errors } = resolveActionMap(actions, options.controlledDrivers, {
51
+ policy: options.actionPolicy,
52
+ });
53
+
54
+ Object.entries(controlsByDriver).forEach(([driverId, controls]) => {
55
+ sim.setCarControls(driverId, controls);
56
+ });
57
+
58
+ episodeState.previousSnapshot = sim.snapshot();
59
+ const stepEvents = [];
60
+ for (let index = 0; index < options.frameSkip; index += 1) {
61
+ sim.step(FIXED_STEP);
62
+ stepEvents.push(...collectStepEvents(sim.snapshot().events));
63
+ }
64
+ episodeState.step += 1;
65
+ const result = buildResult({ host, episodeState, events: stepEvents, actionErrors: errors, actions });
66
+ episodeState.lastResult = result;
67
+ host.afterStep(result);
68
+ return result;
69
+ }
70
+
71
+ function getObservation() {
72
+ return episodeState.lastResult?.observation ??
73
+ buildResult({ host, episodeState, events: [], actionErrors: [] }).observation;
74
+ }
75
+
76
+ function getState() {
77
+ return { snapshot: host.getSimulation().snapshot() };
78
+ }
79
+
80
+ function getActionSpec() {
81
+ return buildActionSpec(host.getOptions());
82
+ }
83
+
84
+ function getObservationSpec() {
85
+ return buildObservationSpec(host.getOptions());
86
+ }
87
+
88
+ function destroy() {
89
+ episodeState.lastResult = null;
90
+ episodeState.previousSnapshot = null;
91
+ }
92
+
93
+ return { reset, step, getObservation, getState, getActionSpec, getObservationSpec, destroy };
94
+ }
95
+
96
+ function buildResult({ host, episodeState, events, actionErrors, actions = {} }) {
97
+ const options = host.getOptions();
98
+ const snapshot = host.getSimulation().snapshot();
99
+ const observation = buildEnvironmentObservation({
100
+ snapshot,
101
+ previousSnapshot: episodeState.previousSnapshot,
102
+ options,
103
+ events,
104
+ });
105
+ const episode = evaluateEpisode(snapshot, options, episodeState);
106
+ const reward = computeReward({ options, observation, events, snapshot, actions, previousSnapshot: episodeState.previousSnapshot });
107
+ return {
108
+ observation,
109
+ reward,
110
+ terminated: episode.terminated,
111
+ truncated: episode.truncated,
112
+ done: episode.terminated || episode.truncated,
113
+ events,
114
+ state: { snapshot },
115
+ info: {
116
+ step: episodeState.step,
117
+ elapsedSeconds: snapshot.time,
118
+ seed: options.seed,
119
+ trackSeed: options.trackSeed,
120
+ controlledDrivers: [...options.controlledDrivers],
121
+ actionErrors,
122
+ endReason: episode.endReason,
123
+ },
124
+ };
125
+ }
126
+
127
+ function computeReward({ options, observation, events, snapshot, actions, previousSnapshot }) {
128
+ if (!options.reward) return null;
129
+ return Object.fromEntries(options.controlledDrivers.map((driverId) => [
130
+ driverId,
131
+ Number(options.reward({
132
+ driverId,
133
+ previous: previousSnapshot,
134
+ current: observation[driverId],
135
+ action: actions?.[driverId],
136
+ events: observation[driverId]?.events ?? events,
137
+ state: { snapshot },
138
+ }) ?? 0),
139
+ ]));
140
+ }
@@ -0,0 +1,257 @@
1
+ import { nearestTrackState } from '../simulation/trackModel.js';
2
+ import { metersToSimUnits, simUnitsToMeters } from '../simulation/units.js';
3
+ import { VEHICLE_LIMITS } from '../simulation/vehiclePhysics.js';
4
+
5
+ const TRACK_RAY_STEP_METERS = 1;
6
+ const TRACK_RAY_REFINE_STEPS = 12;
7
+ export const DEFAULT_RAY_ANGLES_DEGREES = [-135, -60, -20, 0, 20, 60, 135, 180];
8
+
9
+ export function buildNearbyCars(car, snapshot, { maxCars = 6, radiusMeters = 150 } = {}) {
10
+ return snapshot.cars
11
+ .filter((other) => other.id !== car.id)
12
+ .map((other) => {
13
+ const dx = other.x - car.x;
14
+ const dy = other.y - car.y;
15
+ const distanceMeters = simUnitsToMeters(Math.hypot(dx, dy));
16
+ const forward = Math.cos(car.heading) * dx + Math.sin(car.heading) * dy;
17
+ const right = -Math.sin(car.heading) * dx + Math.cos(car.heading) * dy;
18
+ return {
19
+ id: other.id,
20
+ relativeForwardMeters: simUnitsToMeters(forward),
21
+ relativeRightMeters: simUnitsToMeters(right),
22
+ relativeDistanceMeters: distanceMeters,
23
+ relativeSpeedKph: other.speedKph - car.speedKph,
24
+ relativeHeadingRadians: normalizeRelativeHeading(other.heading - car.heading),
25
+ ahead: forward > 0,
26
+ sameLap: other.lap === car.lap,
27
+ };
28
+ })
29
+ .filter((entry) => entry.relativeDistanceMeters <= radiusMeters)
30
+ .sort((a, b) => a.relativeDistanceMeters - b.relativeDistanceMeters)
31
+ .slice(0, maxCars);
32
+ }
33
+
34
+ export function buildRaySensors(car, snapshot, rayOptions = {}) {
35
+ const angles = rayOptions.anglesDegrees ?? DEFAULT_RAY_ANGLES_DEGREES;
36
+ const lengthMeters = rayOptions.lengthMeters ?? 120;
37
+
38
+ return angles.map((angleDegrees) => ({
39
+ angleDegrees,
40
+ angleRadians: degreesToRadians(angleDegrees),
41
+ lengthMeters,
42
+ track: rayOptions.detectTrack === false
43
+ ? createTrackMiss(lengthMeters)
44
+ : estimateTrackHit(car, snapshot, angleDegrees, lengthMeters),
45
+ car: rayOptions.detectCars === false
46
+ ? { hit: false, distanceMeters: lengthMeters, driverId: null, relativeSpeedKph: 0 }
47
+ : estimateCarHit(car, snapshot, angleDegrees, lengthMeters),
48
+ }));
49
+ }
50
+
51
+ function estimateTrackHit(car, snapshot, angleDegrees, lengthMeters) {
52
+ if (!Array.isArray(snapshot.track?.samples) || snapshot.track.samples.length === 0) {
53
+ return estimateLocalTrackHit(car, snapshot, angleDegrees, lengthMeters);
54
+ }
55
+
56
+ const origin = getCarRayOrigin(car);
57
+ const ray = getCarRayVector(car, angleDegrees);
58
+ const maxDistance = metersToSimUnits(lengthMeters);
59
+ const step = metersToSimUnits(TRACK_RAY_STEP_METERS);
60
+ let previousDistance = 0;
61
+ let previousInside = null;
62
+
63
+ for (let distance = 0; distance <= maxDistance; distance += step) {
64
+ const state = nearestTrackState(snapshot.track, pointOnRay(origin, ray, distance), car.progress);
65
+ const inside = isInsideTrackBorder(state, snapshot.track);
66
+ if (previousInside == null) {
67
+ previousInside = inside;
68
+ previousDistance = distance;
69
+ continue;
70
+ }
71
+
72
+ if (inside !== previousInside) {
73
+ const kind = previousInside ? 'exit' : 'entry';
74
+ const hitDistance = refineTrackTransitionDistance(
75
+ snapshot.track,
76
+ origin,
77
+ ray,
78
+ car.progress,
79
+ previousDistance,
80
+ distance,
81
+ kind,
82
+ );
83
+ return {
84
+ hit: true,
85
+ distanceMeters: simUnitsToMeters(hitDistance),
86
+ kind,
87
+ };
88
+ }
89
+ previousDistance = distance;
90
+ previousInside = inside;
91
+ }
92
+
93
+ return createTrackMiss(lengthMeters);
94
+ }
95
+
96
+ function estimateLocalTrackHit(car, snapshot, angleDegrees, lengthMeters) {
97
+ const trackHalfWidthMeters = simUnitsToMeters(snapshot.track.width / 2);
98
+ const offsetMeters = simUnitsToMeters(car.signedOffset ?? 0);
99
+ const lateral = Math.sin(degreesToRadians(angleDegrees));
100
+ if (Math.abs(lateral) < 0.08) {
101
+ return createTrackMiss(lengthMeters);
102
+ }
103
+
104
+ const inside = Math.abs(offsetMeters) <= trackHalfWidthMeters;
105
+ const targetEdge = getLocalTrackTransitionTarget({ inside, offsetMeters, lateral, trackHalfWidthMeters });
106
+ if (targetEdge == null) return createTrackMiss(lengthMeters);
107
+
108
+ const distanceMeters = (targetEdge - offsetMeters) / lateral;
109
+ if (distanceMeters < 0 || distanceMeters > lengthMeters) return createTrackMiss(lengthMeters);
110
+
111
+ return {
112
+ hit: true,
113
+ distanceMeters,
114
+ kind: inside ? 'exit' : 'entry',
115
+ };
116
+ }
117
+
118
+ function getLocalTrackTransitionTarget({ inside, offsetMeters, lateral, trackHalfWidthMeters }) {
119
+ if (inside) return lateral > 0 ? trackHalfWidthMeters : -trackHalfWidthMeters;
120
+ if (offsetMeters > trackHalfWidthMeters) return lateral < 0 ? trackHalfWidthMeters : null;
121
+ if (offsetMeters < -trackHalfWidthMeters) return lateral > 0 ? -trackHalfWidthMeters : null;
122
+ return null;
123
+ }
124
+
125
+ function refineTrackTransitionDistance(track, origin, ray, progressHint, lowDistance, highDistance, kind) {
126
+ let low = lowDistance;
127
+ let high = highDistance;
128
+ for (let index = 0; index < TRACK_RAY_REFINE_STEPS; index += 1) {
129
+ const middle = (low + high) / 2;
130
+ const state = nearestTrackState(track, pointOnRay(origin, ray, middle), progressHint);
131
+ const inside = isInsideTrackBorder(state, track);
132
+ if (kind === 'entry') {
133
+ if (inside) high = middle;
134
+ else low = middle;
135
+ } else if (inside) {
136
+ low = middle;
137
+ } else {
138
+ high = middle;
139
+ }
140
+ }
141
+ return high;
142
+ }
143
+
144
+ function isInsideTrackBorder(state, track) {
145
+ return state.crossTrackError <= track.width / 2;
146
+ }
147
+
148
+ function createTrackMiss(lengthMeters) {
149
+ return {
150
+ hit: false,
151
+ distanceMeters: lengthMeters,
152
+ kind: null,
153
+ };
154
+ }
155
+
156
+ function estimateCarHit(car, snapshot, angleDegrees, lengthMeters) {
157
+ const origin = getCarRayOrigin(car);
158
+ const ray = getCarRayVector(car, angleDegrees);
159
+ const maxDistance = metersToSimUnits(lengthMeters);
160
+ let closest = null;
161
+ snapshot.cars.forEach((other) => {
162
+ if (other.id === car.id) return;
163
+ const hitDistance = intersectCarFootprint(origin, ray, other);
164
+ if (hitDistance == null || hitDistance > maxDistance) return;
165
+ const distanceMeters = simUnitsToMeters(hitDistance);
166
+ if (!closest || hitDistance < closest.distanceSimUnits) {
167
+ closest = {
168
+ hit: true,
169
+ distanceSimUnits: hitDistance,
170
+ distanceMeters,
171
+ driverId: other.id,
172
+ relativeSpeedKph: other.speedKph - car.speedKph,
173
+ };
174
+ }
175
+ });
176
+ if (!closest) return { hit: false, distanceMeters: lengthMeters, driverId: null, relativeSpeedKph: 0 };
177
+ const { distanceSimUnits, ...publicHit } = closest;
178
+ return publicHit;
179
+ }
180
+
181
+ export function getCarRayOrigin(car) {
182
+ return {
183
+ x: car.x,
184
+ y: car.y,
185
+ };
186
+ }
187
+
188
+ export function getCarRayVector(car, angleDegrees) {
189
+ const angle = car.heading + degreesToRadians(angleDegrees);
190
+ return {
191
+ x: Math.cos(angle),
192
+ y: Math.sin(angle),
193
+ };
194
+ }
195
+
196
+ function pointOnRay(origin, ray, distance) {
197
+ return {
198
+ x: origin.x + ray.x * distance,
199
+ y: origin.y + ray.y * distance,
200
+ };
201
+ }
202
+
203
+ function intersectCarFootprint(origin, ray, other) {
204
+ const forward = { x: Math.cos(other.heading), y: Math.sin(other.heading) };
205
+ const right = { x: -Math.sin(other.heading), y: Math.cos(other.heading) };
206
+ const delta = { x: origin.x - other.x, y: origin.y - other.y };
207
+ const localOrigin = {
208
+ x: dot(delta, forward),
209
+ y: dot(delta, right),
210
+ };
211
+ const localRay = {
212
+ x: dot(ray, forward),
213
+ y: dot(ray, right),
214
+ };
215
+ const halfLength = VEHICLE_LIMITS.carLength / 2;
216
+ const halfWidth = VEHICLE_LIMITS.carWidth / 2;
217
+ return intersectAxisAlignedBoxRay(localOrigin, localRay, halfLength, halfWidth);
218
+ }
219
+
220
+ function intersectAxisAlignedBoxRay(origin, ray, halfLength, halfWidth) {
221
+ let tMin = -Infinity;
222
+ let tMax = Infinity;
223
+ const xRange = intersectSlab(origin.x, ray.x, -halfLength, halfLength);
224
+ const yRange = intersectSlab(origin.y, ray.y, -halfWidth, halfWidth);
225
+ if (!xRange || !yRange) return null;
226
+ tMin = Math.max(tMin, xRange.min, yRange.min);
227
+ tMax = Math.min(tMax, xRange.max, yRange.max);
228
+ if (tMax < 0 || tMin > tMax) return null;
229
+ return Math.max(0, tMin);
230
+ }
231
+
232
+ function intersectSlab(origin, direction, min, max) {
233
+ if (Math.abs(direction) < 1e-9) {
234
+ return origin >= min && origin <= max ? { min: -Infinity, max: Infinity } : null;
235
+ }
236
+ const first = (min - origin) / direction;
237
+ const second = (max - origin) / direction;
238
+ return {
239
+ min: Math.min(first, second),
240
+ max: Math.max(first, second),
241
+ };
242
+ }
243
+
244
+ function dot(a, b) {
245
+ return a.x * b.x + a.y * b.y;
246
+ }
247
+
248
+ function degreesToRadians(degrees) {
249
+ return (degrees * Math.PI) / 180;
250
+ }
251
+
252
+ function normalizeRelativeHeading(angle) {
253
+ let value = angle;
254
+ while (value > Math.PI) value -= Math.PI * 2;
255
+ while (value < -Math.PI) value += Math.PI * 2;
256
+ return value;
257
+ }
@@ -0,0 +1,79 @@
1
+ export function buildActionSpec(options) {
2
+ return {
3
+ version: 1,
4
+ controlledDrivers: [...options.controlledDrivers],
5
+ action: {
6
+ type: 'continuous',
7
+ perDriver: {
8
+ steering: { min: -1, max: 1, unit: 'normalized' },
9
+ throttle: { min: 0, max: 1, unit: 'normalized' },
10
+ brake: { min: 0, max: 1, unit: 'normalized' },
11
+ },
12
+ },
13
+ };
14
+ }
15
+
16
+ export function buildObservationSpec(options) {
17
+ const rayOptions = options.sensors.rays;
18
+ const nearbyOptions = options.sensors.nearbyCars;
19
+ return {
20
+ version: 1,
21
+ controlledDrivers: [...options.controlledDrivers],
22
+ object: {
23
+ self: [
24
+ { name: 'id', unit: 'id' },
25
+ { name: 'speedKph', unit: 'kph' },
26
+ { name: 'speedMetersPerSecond', unit: 'm/s' },
27
+ { name: 'headingRadians', unit: 'rad' },
28
+ { name: 'steeringAngleRadians', unit: 'rad' },
29
+ { name: 'throttle', unit: 'normalized' },
30
+ { name: 'brake', unit: 'normalized' },
31
+ { name: 'lap', unit: 'count' },
32
+ { name: 'completedLaps', unit: 'count' },
33
+ { name: 'lapProgressMeters', unit: 'm' },
34
+ { name: 'trackOffsetMeters', unit: 'm' },
35
+ { name: 'trackHeadingErrorRadians', unit: 'rad' },
36
+ { name: 'onTrack', unit: 'boolean' },
37
+ { name: 'surface', unit: 'label' },
38
+ { name: 'tireEnergy', unit: 'nullable:number' },
39
+ ],
40
+ race: [
41
+ { name: 'position', unit: 'rank' },
42
+ { name: 'totalCars', unit: 'count' },
43
+ { name: 'raceMode', unit: 'label' },
44
+ { name: 'totalLaps', unit: 'count' },
45
+ ],
46
+ rays: {
47
+ enabled: Boolean(rayOptions.enabled),
48
+ anglesDegrees: [...rayOptions.anglesDegrees],
49
+ lengthMeters: rayOptions.lengthMeters,
50
+ track: {
51
+ distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
52
+ hit: { unit: 'boolean' },
53
+ kind: { values: ['exit', 'entry', null] },
54
+ },
55
+ car: {
56
+ distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
57
+ hit: { unit: 'boolean' },
58
+ driverId: { nullable: true },
59
+ relativeSpeedKph: { unit: 'kph' },
60
+ },
61
+ },
62
+ nearbyCars: {
63
+ enabled: Boolean(nearbyOptions.enabled),
64
+ maxCars: nearbyOptions.maxCars,
65
+ radiusMeters: nearbyOptions.radiusMeters,
66
+ },
67
+ events: { type: 'array' },
68
+ },
69
+ vector: {
70
+ schema: [
71
+ { name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
72
+ { name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
73
+ { name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
74
+ { name: 'self.onTrack', scale: 'boolean' },
75
+ { name: 'race.position', scale: 'fixed:field-position' },
76
+ ],
77
+ },
78
+ };
79
+ }
package/src/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
2
+
3
+ export type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
4
+
1
5
  export type TireCompound = 'S' | 'M' | 'H';
2
- export type CameraMode = 'overview' | 'leader' | 'selected';
6
+ export type CameraMode = 'overview' | 'leader' | 'selected' | 'show-all';
3
7
  export type RaceBannerMode = 'project' | 'radio' | 'hidden';
4
8
  export type RaceBannerEnabledMode = 'project' | 'radio';
5
9
  export type RaceDataBannerSize = 'auto' | 'custom';
@@ -179,6 +183,7 @@ export interface F1SimulatorUiOptions {
179
183
  enabled?: true | false | RaceBannerEnabledMode[];
180
184
  };
181
185
  raceDataBannerSize?: RaceDataBannerSize;
186
+ raceDataTelemetryDetail?: boolean;
182
187
  timingTowerVerticalFit?: TimingTowerVerticalFit;
183
188
  }
184
189
 
@@ -323,6 +328,31 @@ export interface F1SimulatorCallbacks {
323
328
  }) => void;
324
329
  }
325
330
 
331
+ export interface F1SimulatorExpertOptions {
332
+ enabled: boolean;
333
+ controlledDrivers: string[];
334
+ frameSkip?: number;
335
+ visualizeSensors?: boolean | {
336
+ rays?: boolean;
337
+ };
338
+ }
339
+
340
+ export interface F1SimulatorExpertAction {
341
+ steering: number;
342
+ throttle: number;
343
+ brake: number;
344
+ }
345
+
346
+ export interface F1SimulatorExpertApi {
347
+ reset(options?: unknown): unknown;
348
+ step(actions: Record<string, F1SimulatorExpertAction>): unknown;
349
+ getObservation(): unknown;
350
+ getState(): unknown;
351
+ getActionSpec(): PaddockActionSpec;
352
+ getObservationSpec(): PaddockObservationSpec;
353
+ destroy(): void;
354
+ }
355
+
326
356
  export interface F1SimulatorOptions extends F1SimulatorCallbacks {
327
357
  preset?: PaddockPresetName;
328
358
  drivers: SimulatorDriver[];
@@ -339,8 +369,11 @@ export interface F1SimulatorOptions extends F1SimulatorCallbacks {
339
369
  showBackLink?: boolean;
340
370
  ui?: F1SimulatorUiOptions;
341
371
  assets?: F1SimulatorAssets;
372
+ expert?: F1SimulatorExpertOptions;
342
373
  }
343
374
 
375
+ export type F1SimulatorRestartOptions = Partial<Omit<F1SimulatorOptions, 'assets' | 'expert'>>;
376
+
344
377
  export interface MountRaceCanvasOptions {
345
378
  includeRaceDataPanel?: boolean;
346
379
  includeTimingTower?: boolean;
@@ -355,11 +388,13 @@ export interface MountTelemetryPanelOptions {
355
388
  export interface MountRaceTelemetryDrawerOptions {
356
389
  timingTowerVerticalFit?: TimingTowerVerticalFit;
357
390
  drawerInitiallyOpen?: boolean;
391
+ raceDataTelemetryDetail?: boolean;
358
392
  }
359
393
 
360
394
  export interface F1MountedSimulator {
395
+ readonly expert: F1SimulatorExpertApi | null;
361
396
  destroy(): void;
362
- restart(nextOptions?: Partial<F1SimulatorOptions>): void;
397
+ restart(nextOptions?: F1SimulatorRestartOptions): void;
363
398
  selectDriver(driverId: string): void;
364
399
  setSafetyCarDeployed(deployed: boolean): void;
365
400
  callSafetyCar(): void;
@@ -369,6 +404,7 @@ export interface F1MountedSimulator {
369
404
  }
370
405
 
371
406
  export interface PaddockSimulatorController {
407
+ readonly expert: F1SimulatorExpertApi | null;
372
408
  mountRaceControls<T extends Element>(root: T): T;
373
409
  mountCameraControls<T extends Element>(root: T): T;
374
410
  mountSafetyCarControl<T extends Element>(root: T): T;
@@ -387,7 +423,7 @@ export interface PaddockSimulatorController {
387
423
  querySelectorAll(selector: string): Element[];
388
424
  start(): Promise<PaddockSimulatorController>;
389
425
  destroy(): void;
390
- restart(nextOptions?: Partial<F1SimulatorOptions>): void;
426
+ restart(nextOptions?: F1SimulatorRestartOptions): void;
391
427
  selectDriver(driverId: string): void;
392
428
  setSafetyCarDeployed(deployed: boolean): void;
393
429
  callSafetyCar(): void;
@@ -416,7 +452,7 @@ export function buildChampionshipDriverGrid(
416
452
  export function formatDriverNumber(driverNumber: number | string | null | undefined): string;
417
453
  export function normalizeSimulatorDrivers(
418
454
  drivers: SimulatorDriver[],
419
- options?: { entries?: ChampionshipEntryBlueprint[] },
455
+ options?: { entries?: ChampionshipEntryBlueprint[]; caller?: string },
420
456
  ): NormalizedSimulatorDriver[];
421
457
 
422
458
  export function metersToSimUnits(meters: number): number;
package/src/index.js CHANGED
@@ -91,13 +91,17 @@ export async function mountF1Simulator(root, options = {}) {
91
91
  await app.init();
92
92
 
93
93
  return {
94
+ get expert() {
95
+ return app.expert ?? null;
96
+ },
94
97
  destroy() {
95
98
  app.destroy();
96
99
  root.innerHTML = '';
97
100
  },
98
101
  restart(nextOptions = {}) {
99
- resolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(resolvedOptions, nextOptions));
100
- app.restart(resolvedOptions);
102
+ const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(resolvedOptions, nextOptions));
103
+ app.restart(nextResolvedOptions);
104
+ resolvedOptions = nextResolvedOptions;
101
105
  },
102
106
  selectDriver(driverId) {
103
107
  app.selectCar(driverId, { focus: true });
@@ -48,6 +48,12 @@ function textureOrWhite(texture) {
48
48
  return texture ?? Texture.WHITE;
49
49
  }
50
50
 
51
+ function destroyDisplayChildren(container) {
52
+ container.removeChildren().forEach((child) => {
53
+ child.destroy?.({ children: true, texture: false, textureSource: false });
54
+ });
55
+ }
56
+
51
57
  function arcDistance(track, first, second) {
52
58
  const delta = Math.abs(first - second);
53
59
  return Math.min(delta, track.length - delta);
@@ -269,7 +275,7 @@ export class ProceduralTrackAsset {
269
275
  }
270
276
 
271
277
  render(track) {
272
- this.container.removeChildren();
278
+ destroyDisplayChildren(this.container);
273
279
  this.addGrass();
274
280
  this.addGravelRunoff(track);
275
281
  this.addBoundaryUnderlay(track);