@inventure71/paddockjs 0.2.0 → 1.0.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.
- package/CHANGELOG.md +45 -0
- package/INSTALL_AND_UPDATE.md +165 -0
- package/README.md +217 -20
- package/docs/architecture.md +84 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +412 -20
- package/docs/data_model.md +2 -1
- package/docs/index.md +1 -0
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +74 -20
- package/docs/training.md +206 -0
- package/examples/train-basic-policy.mjs +276 -0
- package/examples/trainingData.mjs +133 -0
- package/package.json +18 -8
- package/src/api/PaddockSimulatorController.js +39 -4
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +1251 -146
- package/src/app/domBindings.js +10 -2
- package/src/config/defaultOptions.js +11 -3
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +68 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/evaluation.js +184 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +521 -0
- package/src/environment/index.js +15 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +284 -0
- package/src/environment/options.js +157 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +175 -0
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +367 -0
- package/src/environment/specs.js +163 -0
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +405 -6
- package/src/index.js +27 -2
- package/src/rendering/proceduralTrackAsset.js +343 -24
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2140 -171
- package/src/simulation/rules/collisionSteward.js +69 -0
- package/src/simulation/rules/penaltyLedger.js +153 -0
- package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
- package/src/simulation/rules/tireRequirementSteward.js +29 -0
- package/src/simulation/rules/trackLimitsSteward.js +85 -0
- package/src/simulation/rulesConfig.js +352 -0
- package/src/simulation/trackModel.js +1154 -76
- package/src/simulation/units.js +6 -4
- package/src/simulation/vehicleGeometry.js +247 -0
- package/src/simulation/vehiclePhysics.js +82 -50
- package/src/simulation/wheelSurface.js +338 -0
- package/src/styles.css +369 -67
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +45 -81
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { metersToSimUnits, simSpeedToMetersPerSecond, simUnitsToMeters } from '../simulation/units.js';
|
|
2
|
+
import { normalizeAngle } from '../simulation/simMath.js';
|
|
3
|
+
import { pointAt } from '../simulation/trackModel.js';
|
|
4
|
+
import { normalizeLookaheadMeters } from './observationOptions.js';
|
|
5
|
+
import { buildNearbyCars, buildRaySensors } from './sensors.js';
|
|
6
|
+
|
|
7
|
+
export function buildEnvironmentObservation({ snapshot, options, events = [] }) {
|
|
8
|
+
const carsById = new Map(snapshot.cars.map((entry) => [entry.id, entry]));
|
|
9
|
+
const eventsByDriver = groupEventsByDriver(events, options.controlledDrivers);
|
|
10
|
+
return Object.fromEntries(options.controlledDrivers.map((driverId) => {
|
|
11
|
+
const car = carsById.get(driverId);
|
|
12
|
+
if (!car) return [driverId, emptyObservation(driverId)];
|
|
13
|
+
|
|
14
|
+
const driverEvents = eventsByDriver.get(driverId) ?? [];
|
|
15
|
+
const sensors = effectiveSensorOptions(options, car.id);
|
|
16
|
+
const object = buildDriverObservationObject(car, snapshot, options, driverEvents, sensors);
|
|
17
|
+
const { vector, schema } = buildDriverVector(object, sensors);
|
|
18
|
+
return [driverId, { object, vector, schema, events: driverEvents }];
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function groupEventsByDriver(events, controlledDrivers) {
|
|
23
|
+
const byDriver = new Map(controlledDrivers.map((driverId) => [driverId, []]));
|
|
24
|
+
if (!events.length) return byDriver;
|
|
25
|
+
const controlledSet = new Set(controlledDrivers);
|
|
26
|
+
events.forEach((event) => {
|
|
27
|
+
const driverIds = new Set([
|
|
28
|
+
event.driverId,
|
|
29
|
+
event.carId,
|
|
30
|
+
event.otherCarId,
|
|
31
|
+
...(event.driverIds ?? []),
|
|
32
|
+
].filter(Boolean));
|
|
33
|
+
driverIds.forEach((driverId) => {
|
|
34
|
+
if (controlledSet.has(driverId)) byDriver.get(driverId)?.push(event);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
return byDriver;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildDriverObservationObject(car, snapshot, options, events, sensors) {
|
|
41
|
+
return {
|
|
42
|
+
self: {
|
|
43
|
+
id: car.id,
|
|
44
|
+
speedKph: car.speedKph,
|
|
45
|
+
speedMetersPerSecond: simSpeedToMetersPerSecond(car.speed ?? 0),
|
|
46
|
+
headingRadians: car.heading,
|
|
47
|
+
steeringAngleRadians: car.steeringAngle ?? 0,
|
|
48
|
+
throttle: car.throttle ?? 0,
|
|
49
|
+
brake: car.brake ?? 0,
|
|
50
|
+
lap: car.lap,
|
|
51
|
+
completedLaps: car.lapTelemetry?.completedLaps ?? 0,
|
|
52
|
+
lapProgressMeters: simUnitsToMeters(car.progress ?? 0),
|
|
53
|
+
trackOffsetMeters: simUnitsToMeters(car.signedOffset ?? 0),
|
|
54
|
+
trackHeadingErrorRadians: car.trackHeadingError ?? estimateTrackHeadingError(car, snapshot),
|
|
55
|
+
onTrack: isCarLegallyOnTrack(car),
|
|
56
|
+
surface: car.surface ?? 'track',
|
|
57
|
+
inPitLane: Boolean(car.inPitLane),
|
|
58
|
+
pitLanePart: car.pitLanePart ?? null,
|
|
59
|
+
pitBoxId: car.pitBoxId ?? null,
|
|
60
|
+
tireEnergy: car.tireEnergy ?? null,
|
|
61
|
+
pitIntent: car.pitIntent ?? car.pitStop?.intent ?? 0,
|
|
62
|
+
pitTargetCompound: car.pitStop?.targetTire ?? null,
|
|
63
|
+
pitStopStatus: car.pitStop?.status ?? null,
|
|
64
|
+
pitStopPhase: car.pitStop?.phase ?? null,
|
|
65
|
+
pitStopServiceRemainingSeconds: car.pitStop?.serviceRemainingSeconds ?? null,
|
|
66
|
+
pitStopPenaltyServiceRemainingSeconds: car.pitStop?.penaltyServiceRemainingSeconds ?? null,
|
|
67
|
+
pitStopsCompleted: car.pitStop?.stopsCompleted ?? 0,
|
|
68
|
+
},
|
|
69
|
+
race: {
|
|
70
|
+
position: car.rank,
|
|
71
|
+
totalCars: snapshot.cars.length,
|
|
72
|
+
raceMode: snapshot.raceControl.mode,
|
|
73
|
+
pitLaneOpen: Boolean(snapshot.raceControl.pitLaneOpen),
|
|
74
|
+
redFlag: Boolean(snapshot.raceControl.redFlag),
|
|
75
|
+
totalLaps: snapshot.totalLaps,
|
|
76
|
+
},
|
|
77
|
+
track: {
|
|
78
|
+
lengthMeters: simUnitsToMeters(snapshot.track.length ?? 0),
|
|
79
|
+
widthMeters: simUnitsToMeters(snapshot.track.width ?? 0),
|
|
80
|
+
curvature: car.trackState?.curvature ?? pointAt(snapshot.track, car.progress ?? 0).curvature ?? 0,
|
|
81
|
+
lookahead: buildTrackLookahead(car, snapshot, options),
|
|
82
|
+
},
|
|
83
|
+
rays: sensors.rays.enabled ? buildRaySensors(car, snapshot, sensors.rays) : [],
|
|
84
|
+
nearbyCars: sensors.nearbyCars.enabled ? buildNearbyCars(car, snapshot, sensors.nearbyCars) : [],
|
|
85
|
+
events,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function estimateTrackHeadingError(car, snapshot) {
|
|
90
|
+
const base = pointAt(snapshot.track, car.progress ?? 0);
|
|
91
|
+
return normalizeAngle((car.heading ?? base.heading) - base.heading);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildTrackLookahead(car, snapshot, options) {
|
|
95
|
+
const distances = normalizeLookaheadMeters(options.observation?.lookaheadMeters);
|
|
96
|
+
const base = pointAt(snapshot.track, car.progress ?? 0);
|
|
97
|
+
return distances.map((distanceMeters) => {
|
|
98
|
+
const sample = pointAt(snapshot.track, (car.progress ?? 0) + metersToSimUnits(distanceMeters));
|
|
99
|
+
return {
|
|
100
|
+
distanceMeters,
|
|
101
|
+
curvature: sample.curvature ?? 0,
|
|
102
|
+
headingDeltaRadians: normalizeAngle(sample.heading - base.heading),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isCarLegallyOnTrack(car) {
|
|
108
|
+
if (Array.isArray(car.wheels) && car.wheels.length > 0) {
|
|
109
|
+
return car.wheels.every((wheel) => wheel.onTrack || wheel.inPitLane);
|
|
110
|
+
}
|
|
111
|
+
if (car.inPitLane) return true;
|
|
112
|
+
return ['track', 'kerb', 'pit-entry', 'pit-lane', 'pit-exit', 'pit-box'].includes(car.surface ?? 'track');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildDriverVector(object, sensors) {
|
|
116
|
+
const schema = [
|
|
117
|
+
{ name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
|
|
118
|
+
{ name: 'self.speedMetersPerSecond', unit: 'm/s', scale: 'fixed:120' },
|
|
119
|
+
{ name: 'self.steeringAngleRadians', unit: 'rad', scale: 'fixed:pi' },
|
|
120
|
+
{ name: 'self.throttle', scale: '0..1' },
|
|
121
|
+
{ name: 'self.brake', scale: '0..1' },
|
|
122
|
+
{ name: 'self.lapProgressRatio', scale: '0..1' },
|
|
123
|
+
{ name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
|
|
124
|
+
{ name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
|
|
125
|
+
{ name: 'self.onTrack', scale: 'boolean' },
|
|
126
|
+
{ name: 'self.inPitLane', scale: 'boolean' },
|
|
127
|
+
{ name: 'self.tireEnergy', scale: '0..100' },
|
|
128
|
+
{ name: 'self.pitIntent', scale: '0..2' },
|
|
129
|
+
{ name: 'self.pitStopActive', scale: 'boolean' },
|
|
130
|
+
{ name: 'race.positionNormalized', scale: '0..1' },
|
|
131
|
+
{ name: 'race.raceModeGreen', scale: 'boolean' },
|
|
132
|
+
{ name: 'race.raceModeSafetyCar', scale: 'boolean' },
|
|
133
|
+
{ name: 'race.redFlag', scale: 'boolean' },
|
|
134
|
+
{ name: 'race.pitLaneOpen', scale: 'boolean' },
|
|
135
|
+
{ name: 'track.curvature', scale: 'track-curvature' },
|
|
136
|
+
];
|
|
137
|
+
const vector = [
|
|
138
|
+
object.self.speedKph / 400,
|
|
139
|
+
object.self.speedMetersPerSecond / 120,
|
|
140
|
+
object.self.steeringAngleRadians / Math.PI,
|
|
141
|
+
object.self.throttle,
|
|
142
|
+
object.self.brake,
|
|
143
|
+
normalizeLapProgress(object),
|
|
144
|
+
object.self.trackOffsetMeters,
|
|
145
|
+
object.self.trackHeadingErrorRadians / Math.PI,
|
|
146
|
+
object.self.onTrack ? 1 : 0,
|
|
147
|
+
object.self.inPitLane ? 1 : 0,
|
|
148
|
+
(object.self.tireEnergy ?? 0) / 100,
|
|
149
|
+
(object.self.pitIntent ?? 0) / 2,
|
|
150
|
+
object.self.pitStopStatus && object.self.pitStopStatus !== 'pending' && object.self.pitStopStatus !== 'completed' ? 1 : 0,
|
|
151
|
+
normalizeRacePosition(object),
|
|
152
|
+
object.race.raceMode === 'green' ? 1 : 0,
|
|
153
|
+
object.race.raceMode === 'safety-car' ? 1 : 0,
|
|
154
|
+
object.race.redFlag ? 1 : 0,
|
|
155
|
+
object.race.pitLaneOpen ? 1 : 0,
|
|
156
|
+
object.track.curvature ?? 0,
|
|
157
|
+
];
|
|
158
|
+
object.track.lookahead.forEach((sample, index) => {
|
|
159
|
+
schema.push(
|
|
160
|
+
{ name: `track.lookahead[${index}].curvature`, scale: 'track-curvature' },
|
|
161
|
+
{ name: `track.lookahead[${index}].headingDeltaRadians`, unit: 'rad', scale: 'fixed:pi' },
|
|
162
|
+
);
|
|
163
|
+
vector.push(sample.curvature ?? 0, (sample.headingDeltaRadians ?? 0) / Math.PI);
|
|
164
|
+
});
|
|
165
|
+
object.rays.forEach((ray, index) => {
|
|
166
|
+
schema.push(
|
|
167
|
+
{ name: `rays[${index}].track.distanceRatio`, scale: '0..1' },
|
|
168
|
+
{ name: `rays[${index}].track.hit`, scale: 'boolean' },
|
|
169
|
+
{ name: `rays[${index}].track.kindExit`, scale: 'boolean' },
|
|
170
|
+
{ name: `rays[${index}].track.kindEntry`, scale: 'boolean' },
|
|
171
|
+
{ name: `rays[${index}].car.distanceRatio`, scale: '0..1' },
|
|
172
|
+
{ name: `rays[${index}].car.hit`, scale: 'boolean' },
|
|
173
|
+
{ name: `rays[${index}].car.relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
|
|
174
|
+
);
|
|
175
|
+
vector.push(
|
|
176
|
+
ratio(ray.track.distanceMeters, ray.lengthMeters),
|
|
177
|
+
ray.track.hit ? 1 : 0,
|
|
178
|
+
ray.track.kind === 'exit' ? 1 : 0,
|
|
179
|
+
ray.track.kind === 'entry' ? 1 : 0,
|
|
180
|
+
ratio(ray.car.distanceMeters, ray.lengthMeters),
|
|
181
|
+
ray.car.hit ? 1 : 0,
|
|
182
|
+
ray.car.relativeSpeedKph / 200,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
const nearbyLimit = sensors.nearbyCars.enabled ? (sensors.nearbyCars.maxCars ?? object.nearbyCars.length) : 0;
|
|
186
|
+
const nearbyRadius = sensors.nearbyCars.radiusMeters ?? 150;
|
|
187
|
+
for (let index = 0; index < nearbyLimit; index += 1) {
|
|
188
|
+
const nearby = object.nearbyCars[index] ?? null;
|
|
189
|
+
schema.push(
|
|
190
|
+
{ name: `nearbyCars[${index}].present`, scale: 'boolean' },
|
|
191
|
+
{ name: `nearbyCars[${index}].relativeForwardRatio`, scale: '-1..1' },
|
|
192
|
+
{ name: `nearbyCars[${index}].relativeRightRatio`, scale: '-1..1' },
|
|
193
|
+
{ name: `nearbyCars[${index}].relativeDistanceRatio`, scale: '0..1' },
|
|
194
|
+
{ name: `nearbyCars[${index}].relativeSpeedKph`, unit: 'kph', scale: 'fixed:200' },
|
|
195
|
+
{ name: `nearbyCars[${index}].relativeHeadingRadians`, unit: 'rad', scale: 'fixed:pi' },
|
|
196
|
+
{ name: `nearbyCars[${index}].ahead`, scale: 'boolean' },
|
|
197
|
+
{ name: `nearbyCars[${index}].sameLap`, scale: 'boolean' },
|
|
198
|
+
);
|
|
199
|
+
vector.push(
|
|
200
|
+
nearby ? 1 : 0,
|
|
201
|
+
clampRatio((nearby?.relativeForwardMeters ?? 0) / nearbyRadius),
|
|
202
|
+
clampRatio((nearby?.relativeRightMeters ?? 0) / nearbyRadius),
|
|
203
|
+
ratio(nearby?.relativeDistanceMeters ?? nearbyRadius, nearbyRadius),
|
|
204
|
+
(nearby?.relativeSpeedKph ?? 0) / 200,
|
|
205
|
+
(nearby?.relativeHeadingRadians ?? 0) / Math.PI,
|
|
206
|
+
nearby?.ahead ? 1 : 0,
|
|
207
|
+
nearby?.sameLap ? 1 : 0,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return { vector, schema };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeLapProgress(object) {
|
|
214
|
+
return ratio(object.self.lapProgressMeters, object.track.lengthMeters || 1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeRacePosition(object) {
|
|
218
|
+
const total = Math.max(1, object.race.totalCars - 1);
|
|
219
|
+
return object.race.totalCars <= 1 ? 0 : (object.race.position - 1) / total;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ratio(value, max) {
|
|
223
|
+
const finite = Number.isFinite(value) ? value : max;
|
|
224
|
+
return Math.max(0, Math.min(1, finite / Math.max(1e-9, max)));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function clampRatio(value) {
|
|
228
|
+
return Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function effectiveSensorOptions(options, driverId) {
|
|
232
|
+
return {
|
|
233
|
+
rays: {
|
|
234
|
+
...options.sensors.rays,
|
|
235
|
+
...(options.sensorsByDriver?.[driverId]?.rays ?? {}),
|
|
236
|
+
},
|
|
237
|
+
nearbyCars: {
|
|
238
|
+
...options.sensors.nearbyCars,
|
|
239
|
+
...(options.sensorsByDriver?.[driverId]?.nearbyCars ?? {}),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function emptyObservation(driverId) {
|
|
245
|
+
return {
|
|
246
|
+
object: {
|
|
247
|
+
self: {
|
|
248
|
+
id: driverId,
|
|
249
|
+
speedKph: 0,
|
|
250
|
+
speedMetersPerSecond: 0,
|
|
251
|
+
headingRadians: 0,
|
|
252
|
+
steeringAngleRadians: 0,
|
|
253
|
+
throttle: 0,
|
|
254
|
+
brake: 0,
|
|
255
|
+
lap: 0,
|
|
256
|
+
completedLaps: 0,
|
|
257
|
+
lapProgressMeters: 0,
|
|
258
|
+
trackOffsetMeters: 0,
|
|
259
|
+
trackHeadingErrorRadians: 0,
|
|
260
|
+
onTrack: false,
|
|
261
|
+
surface: 'missing',
|
|
262
|
+
inPitLane: false,
|
|
263
|
+
pitLanePart: null,
|
|
264
|
+
pitBoxId: null,
|
|
265
|
+
tireEnergy: null,
|
|
266
|
+
pitIntent: 0,
|
|
267
|
+
pitTargetCompound: null,
|
|
268
|
+
pitStopStatus: null,
|
|
269
|
+
pitStopPhase: null,
|
|
270
|
+
pitStopServiceRemainingSeconds: null,
|
|
271
|
+
pitStopPenaltyServiceRemainingSeconds: null,
|
|
272
|
+
pitStopsCompleted: 0,
|
|
273
|
+
},
|
|
274
|
+
race: { position: 0, totalCars: 0, raceMode: 'missing', pitLaneOpen: false, redFlag: false, totalLaps: 0 },
|
|
275
|
+
track: { lengthMeters: 0, widthMeters: 0, curvature: 0, lookahead: [] },
|
|
276
|
+
rays: [],
|
|
277
|
+
nearbyCars: [],
|
|
278
|
+
events: [],
|
|
279
|
+
},
|
|
280
|
+
vector: [],
|
|
281
|
+
schema: [],
|
|
282
|
+
events: [],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../data/championship.js';
|
|
2
|
+
import { normalizeSimulatorDrivers } from '../data/normalizeDrivers.js';
|
|
3
|
+
import { normalizeLookaheadMeters } from './observationOptions.js';
|
|
4
|
+
import { resolveScenarioPlacementConfig } from './scenarios.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SEED = 1971;
|
|
7
|
+
const DEFAULT_FRAME_SKIP = 1;
|
|
8
|
+
const DEFAULT_MAX_STEPS = 10000;
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SENSOR_OPTIONS = {
|
|
11
|
+
rays: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
anglesDegrees: [-135, -60, -20, 0, 20, 60, 135, 180],
|
|
14
|
+
lengthMeters: 120,
|
|
15
|
+
detectTrack: true,
|
|
16
|
+
detectCars: true,
|
|
17
|
+
},
|
|
18
|
+
nearbyCars: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
maxCars: 6,
|
|
21
|
+
radiusMeters: 150,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function resolveEnvironmentOptions(options = {}) {
|
|
26
|
+
if (!Array.isArray(options.controlledDrivers) || options.controlledDrivers.length === 0) {
|
|
27
|
+
throw new Error('PaddockJS environment controlledDrivers is required.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const seed = normalizeSeed(options.seed, createGeneratedSeed);
|
|
31
|
+
const trackSeed = normalizeSeed(options.trackSeed, createGeneratedSeed);
|
|
32
|
+
const normalizedDrivers = normalizeSimulatorDrivers(options.drivers, {
|
|
33
|
+
entries: options.entries ?? CHAMPIONSHIP_ENTRY_BLUEPRINTS,
|
|
34
|
+
caller: 'createPaddockEnvironment',
|
|
35
|
+
});
|
|
36
|
+
const resolved = {
|
|
37
|
+
...options,
|
|
38
|
+
seed,
|
|
39
|
+
trackSeed,
|
|
40
|
+
totalLaps: options.totalLaps,
|
|
41
|
+
drivers: normalizedDrivers,
|
|
42
|
+
};
|
|
43
|
+
const driverIds = new Set(resolved.drivers.map((driver) => driver.id));
|
|
44
|
+
const controlledDrivers = [...new Set(options.controlledDrivers)];
|
|
45
|
+
|
|
46
|
+
controlledDrivers.forEach((driverId) => {
|
|
47
|
+
if (!driverIds.has(driverId)) {
|
|
48
|
+
throw new Error(`PaddockJS environment controlled driver does not exist: ${driverId}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const scenario = resolveScenario(options.scenario, controlledDrivers, driverIds);
|
|
53
|
+
const participants = resolveParticipants(scenario.participants, controlledDrivers, resolved.drivers);
|
|
54
|
+
const participantDrivers = resolved.drivers.filter((driver) => participants.has(driver.id));
|
|
55
|
+
const participantIds = new Set(participantDrivers.map((driver) => driver.id));
|
|
56
|
+
|
|
57
|
+
controlledDrivers.forEach((driverId) => {
|
|
58
|
+
if (!participantIds.has(driverId)) {
|
|
59
|
+
throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...resolved,
|
|
65
|
+
drivers: participantDrivers,
|
|
66
|
+
controlledDrivers,
|
|
67
|
+
frameSkip: normalizePositiveInteger(options.frameSkip, DEFAULT_FRAME_SKIP, 'frameSkip'),
|
|
68
|
+
actionPolicy: options.actionPolicy === 'report' ? 'report' : 'strict',
|
|
69
|
+
scenario,
|
|
70
|
+
observation: {
|
|
71
|
+
...(options.observation ?? {}),
|
|
72
|
+
lookaheadMeters: normalizeLookaheadMeters(options.observation?.lookaheadMeters),
|
|
73
|
+
},
|
|
74
|
+
sensors: mergeSensorOptions(options.sensors),
|
|
75
|
+
sensorsByDriver: options.sensorsByDriver ?? {},
|
|
76
|
+
episode: {
|
|
77
|
+
maxSteps: normalizePositiveInteger(options.episode?.maxSteps, DEFAULT_MAX_STEPS, 'episode.maxSteps'),
|
|
78
|
+
endOnRaceFinish: options.episode?.endOnRaceFinish !== false,
|
|
79
|
+
},
|
|
80
|
+
reward: typeof options.reward === 'function' ? options.reward : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeSeed(value, fallbackFactory) {
|
|
85
|
+
if (value == null) return fallbackFactory();
|
|
86
|
+
const number = Number(value);
|
|
87
|
+
return Number.isFinite(number) ? number : DEFAULT_SEED;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createGeneratedSeed() {
|
|
91
|
+
const values = new Uint32Array(1);
|
|
92
|
+
try {
|
|
93
|
+
globalThis.crypto?.getRandomValues?.(values);
|
|
94
|
+
} catch {
|
|
95
|
+
values[0] = 0;
|
|
96
|
+
}
|
|
97
|
+
return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveScenario(scenario = {}, controlledDrivers, driverIds) {
|
|
101
|
+
const participants = scenario.participants ?? 'all';
|
|
102
|
+
if (
|
|
103
|
+
participants !== 'all' &&
|
|
104
|
+
participants !== 'controlled-only' &&
|
|
105
|
+
!Array.isArray(participants)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error('PaddockJS environment scenario.participants must be "all", "controlled-only", or an array of driver ids.');
|
|
108
|
+
}
|
|
109
|
+
if (scenario.nonControlled != null && scenario.nonControlled !== 'ai') {
|
|
110
|
+
throw new Error('PaddockJS environment first slice only supports scenario.nonControlled: "ai".');
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(participants)) {
|
|
113
|
+
participants.forEach((driverId) => {
|
|
114
|
+
if (!driverIds.has(driverId)) {
|
|
115
|
+
throw new Error(`PaddockJS environment scenario participant does not exist: ${driverId}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
controlledDrivers.forEach((driverId) => {
|
|
119
|
+
if (!participants.includes(driverId)) {
|
|
120
|
+
throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
participants,
|
|
126
|
+
nonControlled: 'ai',
|
|
127
|
+
...resolveScenarioPlacementConfig(scenario, driverIds),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveParticipants(participants, controlledDrivers, drivers) {
|
|
132
|
+
if (participants === 'controlled-only') return new Set(controlledDrivers);
|
|
133
|
+
if (Array.isArray(participants)) return new Set(participants);
|
|
134
|
+
return new Set(drivers.map((driver) => driver.id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizePositiveInteger(value, fallback, label) {
|
|
138
|
+
if (value == null) return fallback;
|
|
139
|
+
const number = Math.floor(Number(value));
|
|
140
|
+
if (!Number.isFinite(number) || number < 1) {
|
|
141
|
+
throw new Error(`PaddockJS environment ${label} must be a positive integer.`);
|
|
142
|
+
}
|
|
143
|
+
return number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mergeSensorOptions(sensors = {}) {
|
|
147
|
+
return {
|
|
148
|
+
rays: {
|
|
149
|
+
...DEFAULT_SENSOR_OPTIONS.rays,
|
|
150
|
+
...(sensors.rays ?? {}),
|
|
151
|
+
},
|
|
152
|
+
nearbyCars: {
|
|
153
|
+
...DEFAULT_SENSOR_OPTIONS.nearbyCars,
|
|
154
|
+
...(sensors.nearbyCars ?? {}),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function createRolloutRecorder() {
|
|
2
|
+
const transitions = [];
|
|
3
|
+
return {
|
|
4
|
+
recordStep(previousResult, action, nextResult) {
|
|
5
|
+
const transition = createRolloutTransition(previousResult, action, nextResult);
|
|
6
|
+
transitions.push(transition);
|
|
7
|
+
return transition;
|
|
8
|
+
},
|
|
9
|
+
clear() {
|
|
10
|
+
transitions.length = 0;
|
|
11
|
+
},
|
|
12
|
+
toJSON() {
|
|
13
|
+
return transitions.map((entry) => ({ ...entry }));
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRolloutTransition(previousResult, action, nextResult) {
|
|
19
|
+
return {
|
|
20
|
+
observation: previousResult.observation,
|
|
21
|
+
action,
|
|
22
|
+
reward: nextResult.reward,
|
|
23
|
+
nextObservation: nextResult.observation,
|
|
24
|
+
terminated: nextResult.terminated,
|
|
25
|
+
truncated: nextResult.truncated,
|
|
26
|
+
info: nextResult.info,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|