@inventure71/paddockjs 0.3.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 +39 -0
- package/INSTALL_AND_UPDATE.md +3 -1
- package/README.md +142 -9
- package/docs/architecture.md +49 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +185 -27
- package/docs/data_model.md +1 -1
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +39 -16
- package/docs/training.md +103 -1
- package/examples/train-basic-policy.mjs +5 -6
- package/examples/trainingData.mjs +133 -0
- package/package.json +5 -2
- package/src/api/PaddockSimulatorController.js +32 -2
- package/src/app/F1SimulatorApp.js +1101 -128
- package/src/app/domBindings.js +10 -1
- package/src/config/defaultOptions.js +4 -4
- package/src/environment/actions.js +25 -2
- package/src/environment/evaluation.js +184 -0
- package/src/environment/index.d.ts +281 -1
- package/src/environment/index.js +13 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +189 -17
- package/src/environment/options.js +7 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/runtime.js +41 -6
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +145 -35
- package/src/environment/specs.js +92 -8
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +366 -3
- package/src/index.js +21 -0
- package/src/rendering/proceduralTrackAsset.js +336 -23
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2132 -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 +300 -42
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +36 -96
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
package/src/environment/index.js
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
export { createPaddockEnvironment } from './runtime.js';
|
|
2
2
|
export { createProgressReward } from './rewards.js';
|
|
3
|
+
export { createRolloutRecorder, createRolloutTransition } from './recorder.js';
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_EVALUATION_CASES,
|
|
6
|
+
createEvaluationTracker,
|
|
7
|
+
runEnvironmentEvaluation,
|
|
8
|
+
} from './evaluation.js';
|
|
9
|
+
export {
|
|
10
|
+
ENVIRONMENT_SCENARIO_PRESETS,
|
|
11
|
+
} from './scenarios.js';
|
|
12
|
+
export {
|
|
13
|
+
createEnvironmentWorkerProtocol,
|
|
14
|
+
handleEnvironmentMessage,
|
|
15
|
+
} from './workerProtocol.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_VECTOR_LOOKAHEAD_METERS = Object.freeze([20, 50, 100, 150]);
|
|
2
|
+
|
|
3
|
+
export function normalizeLookaheadMeters(value) {
|
|
4
|
+
if (value == null) return [...DEFAULT_VECTOR_LOOKAHEAD_METERS];
|
|
5
|
+
if (!Array.isArray(value)) return [...DEFAULT_VECTOR_LOOKAHEAD_METERS];
|
|
6
|
+
|
|
7
|
+
const distances = value
|
|
8
|
+
.map((item) => Number(item))
|
|
9
|
+
.filter((item) => Number.isFinite(item));
|
|
10
|
+
|
|
11
|
+
return distances.length ? distances : [...DEFAULT_VECTOR_LOOKAHEAD_METERS];
|
|
12
|
+
}
|
|
@@ -1,24 +1,43 @@
|
|
|
1
|
-
import { simSpeedToMetersPerSecond, simUnitsToMeters } from '../simulation/units.js';
|
|
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';
|
|
2
5
|
import { buildNearbyCars, buildRaySensors } from './sensors.js';
|
|
3
6
|
|
|
4
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);
|
|
5
10
|
return Object.fromEntries(options.controlledDrivers.map((driverId) => {
|
|
6
|
-
const car =
|
|
11
|
+
const car = carsById.get(driverId);
|
|
7
12
|
if (!car) return [driverId, emptyObservation(driverId)];
|
|
8
13
|
|
|
9
|
-
const driverEvents =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
event.driverIds?.includes?.(driverId));
|
|
14
|
-
const object = buildDriverObservationObject(car, snapshot, options, driverEvents);
|
|
15
|
-
const { vector, schema } = buildDriverVector(object);
|
|
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);
|
|
16
18
|
return [driverId, { object, vector, schema, events: driverEvents }];
|
|
17
19
|
}));
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
function
|
|
21
|
-
const
|
|
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) {
|
|
22
41
|
return {
|
|
23
42
|
self: {
|
|
24
43
|
id: car.id,
|
|
@@ -32,41 +51,183 @@ function buildDriverObservationObject(car, snapshot, options, events) {
|
|
|
32
51
|
completedLaps: car.lapTelemetry?.completedLaps ?? 0,
|
|
33
52
|
lapProgressMeters: simUnitsToMeters(car.progress ?? 0),
|
|
34
53
|
trackOffsetMeters: simUnitsToMeters(car.signedOffset ?? 0),
|
|
35
|
-
trackHeadingErrorRadians: car.trackHeadingError ??
|
|
36
|
-
onTrack: (car
|
|
54
|
+
trackHeadingErrorRadians: car.trackHeadingError ?? estimateTrackHeadingError(car, snapshot),
|
|
55
|
+
onTrack: isCarLegallyOnTrack(car),
|
|
37
56
|
surface: car.surface ?? 'track',
|
|
57
|
+
inPitLane: Boolean(car.inPitLane),
|
|
58
|
+
pitLanePart: car.pitLanePart ?? null,
|
|
59
|
+
pitBoxId: car.pitBoxId ?? null,
|
|
38
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,
|
|
39
68
|
},
|
|
40
69
|
race: {
|
|
41
70
|
position: car.rank,
|
|
42
71
|
totalCars: snapshot.cars.length,
|
|
43
72
|
raceMode: snapshot.raceControl.mode,
|
|
73
|
+
pitLaneOpen: Boolean(snapshot.raceControl.pitLaneOpen),
|
|
74
|
+
redFlag: Boolean(snapshot.raceControl.redFlag),
|
|
44
75
|
totalLaps: snapshot.totalLaps,
|
|
45
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
|
+
},
|
|
46
83
|
rays: sensors.rays.enabled ? buildRaySensors(car, snapshot, sensors.rays) : [],
|
|
47
84
|
nearbyCars: sensors.nearbyCars.enabled ? buildNearbyCars(car, snapshot, sensors.nearbyCars) : [],
|
|
48
85
|
events,
|
|
49
86
|
};
|
|
50
87
|
}
|
|
51
88
|
|
|
52
|
-
function
|
|
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) {
|
|
53
116
|
const schema = [
|
|
54
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' },
|
|
55
123
|
{ name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
|
|
56
124
|
{ name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
|
|
57
125
|
{ name: 'self.onTrack', scale: 'boolean' },
|
|
58
|
-
{ name: '
|
|
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' },
|
|
59
136
|
];
|
|
60
137
|
const vector = [
|
|
61
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),
|
|
62
144
|
object.self.trackOffsetMeters,
|
|
63
145
|
object.self.trackHeadingErrorRadians / Math.PI,
|
|
64
146
|
object.self.onTrack ? 1 : 0,
|
|
65
|
-
object.
|
|
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,
|
|
66
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
|
+
}
|
|
67
210
|
return { vector, schema };
|
|
68
211
|
}
|
|
69
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
|
+
|
|
70
231
|
function effectiveSensorOptions(options, driverId) {
|
|
71
232
|
return {
|
|
72
233
|
rays: {
|
|
@@ -98,9 +259,20 @@ function emptyObservation(driverId) {
|
|
|
98
259
|
trackHeadingErrorRadians: 0,
|
|
99
260
|
onTrack: false,
|
|
100
261
|
surface: 'missing',
|
|
262
|
+
inPitLane: false,
|
|
263
|
+
pitLanePart: null,
|
|
264
|
+
pitBoxId: null,
|
|
101
265
|
tireEnergy: null,
|
|
266
|
+
pitIntent: 0,
|
|
267
|
+
pitTargetCompound: null,
|
|
268
|
+
pitStopStatus: null,
|
|
269
|
+
pitStopPhase: null,
|
|
270
|
+
pitStopServiceRemainingSeconds: null,
|
|
271
|
+
pitStopPenaltyServiceRemainingSeconds: null,
|
|
272
|
+
pitStopsCompleted: 0,
|
|
102
273
|
},
|
|
103
|
-
race: { position: 0, totalCars: 0, raceMode: 'missing', totalLaps: 0 },
|
|
274
|
+
race: { position: 0, totalCars: 0, raceMode: 'missing', pitLaneOpen: false, redFlag: false, totalLaps: 0 },
|
|
275
|
+
track: { lengthMeters: 0, widthMeters: 0, curvature: 0, lookahead: [] },
|
|
104
276
|
rays: [],
|
|
105
277
|
nearbyCars: [],
|
|
106
278
|
events: [],
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../data/championship.js';
|
|
2
2
|
import { normalizeSimulatorDrivers } from '../data/normalizeDrivers.js';
|
|
3
|
+
import { normalizeLookaheadMeters } from './observationOptions.js';
|
|
4
|
+
import { resolveScenarioPlacementConfig } from './scenarios.js';
|
|
3
5
|
|
|
4
6
|
const DEFAULT_SEED = 1971;
|
|
5
7
|
const DEFAULT_FRAME_SKIP = 1;
|
|
@@ -65,6 +67,10 @@ export function resolveEnvironmentOptions(options = {}) {
|
|
|
65
67
|
frameSkip: normalizePositiveInteger(options.frameSkip, DEFAULT_FRAME_SKIP, 'frameSkip'),
|
|
66
68
|
actionPolicy: options.actionPolicy === 'report' ? 'report' : 'strict',
|
|
67
69
|
scenario,
|
|
70
|
+
observation: {
|
|
71
|
+
...(options.observation ?? {}),
|
|
72
|
+
lookaheadMeters: normalizeLookaheadMeters(options.observation?.lookaheadMeters),
|
|
73
|
+
},
|
|
68
74
|
sensors: mergeSensorOptions(options.sensors),
|
|
69
75
|
sensorsByDriver: options.sensorsByDriver ?? {},
|
|
70
76
|
episode: {
|
|
@@ -118,6 +124,7 @@ function resolveScenario(scenario = {}, controlledDrivers, driverIds) {
|
|
|
118
124
|
return {
|
|
119
125
|
participants,
|
|
120
126
|
nonControlled: 'ai',
|
|
127
|
+
...resolveScenarioPlacementConfig(scenario, driverIds),
|
|
121
128
|
};
|
|
122
129
|
}
|
|
123
130
|
|
|
@@ -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
|
+
}
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
|
|
2
|
-
import { resolveActionMap } from './actions.js';
|
|
2
|
+
import { handleActionError, resolveActionMap } from './actions.js';
|
|
3
3
|
import { collectStepEvents } from './events.js';
|
|
4
4
|
import { createEpisodeState, evaluateEpisode } from './episode.js';
|
|
5
5
|
import { buildEnvironmentObservation } from './observations.js';
|
|
6
6
|
import { resolveEnvironmentOptions } from './options.js';
|
|
7
|
+
import { applyEnvironmentScenario } from './scenarios.js';
|
|
7
8
|
import { buildActionSpec, buildObservationSpec } from './specs.js';
|
|
8
9
|
|
|
9
10
|
export function createPaddockEnvironment(options = {}) {
|
|
10
11
|
let resolvedOptions = resolveEnvironmentOptions(options);
|
|
11
|
-
let sim =
|
|
12
|
+
let sim = createSimulationWithEnvironmentScenario(resolvedOptions);
|
|
12
13
|
return createEnvironmentRuntime({
|
|
13
14
|
getSimulation: () => sim,
|
|
14
15
|
setSimulation(nextSim) {
|
|
15
16
|
sim = nextSim;
|
|
16
17
|
},
|
|
17
18
|
createSimulation(nextOptions) {
|
|
18
|
-
return
|
|
19
|
+
return createSimulationWithEnvironmentScenario(nextOptions);
|
|
19
20
|
},
|
|
20
21
|
getOptions: () => resolvedOptions,
|
|
21
22
|
setOptions(nextOptions) {
|
|
@@ -26,9 +27,17 @@ export function createPaddockEnvironment(options = {}) {
|
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function createSimulationWithEnvironmentScenario(options) {
|
|
31
|
+
const sim = createRaceSimulation(options);
|
|
32
|
+
applyEnvironmentScenario(sim, options);
|
|
33
|
+
return sim;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export function createEnvironmentRuntime(host) {
|
|
30
37
|
const episodeState = createEpisodeState();
|
|
31
38
|
|
|
39
|
+
initializeControlledPitIntent(host);
|
|
40
|
+
|
|
32
41
|
function reset(nextOptions = {}) {
|
|
33
42
|
const options = resolveEnvironmentOptions({
|
|
34
43
|
...host.getOptions(),
|
|
@@ -36,6 +45,7 @@ export function createEnvironmentRuntime(host) {
|
|
|
36
45
|
});
|
|
37
46
|
host.setOptions(options);
|
|
38
47
|
host.setSimulation(host.createSimulation(options));
|
|
48
|
+
initializeControlledPitIntent(host);
|
|
39
49
|
episodeState.step = 0;
|
|
40
50
|
episodeState.previousSnapshot = null;
|
|
41
51
|
const result = buildResult({ host, episodeState, events: [], actionErrors: [] });
|
|
@@ -47,19 +57,28 @@ export function createEnvironmentRuntime(host) {
|
|
|
47
57
|
function step(actions = {}) {
|
|
48
58
|
const options = host.getOptions();
|
|
49
59
|
const sim = host.getSimulation();
|
|
50
|
-
const { controlsByDriver, errors } = resolveActionMap(actions, options.controlledDrivers, {
|
|
60
|
+
const { controlsByDriver, pitIntentByDriver, errors } = resolveActionMap(actions, options.controlledDrivers, {
|
|
51
61
|
policy: options.actionPolicy,
|
|
52
62
|
});
|
|
53
63
|
|
|
54
64
|
Object.entries(controlsByDriver).forEach(([driverId, controls]) => {
|
|
55
65
|
sim.setCarControls(driverId, controls);
|
|
56
66
|
});
|
|
67
|
+
Object.entries(pitIntentByDriver).forEach(([driverId, pitIntent]) => {
|
|
68
|
+
const applied = Boolean(sim.setPitIntent?.(driverId, pitIntent));
|
|
69
|
+
if (!applied && !isNoopPitIntent(pitIntent)) {
|
|
70
|
+
handleActionError(`Pit intent could not be applied for controlled driver: ${driverId}`, options.actionPolicy, errors);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
57
73
|
|
|
58
|
-
episodeState.previousSnapshot = sim.snapshot();
|
|
74
|
+
episodeState.previousSnapshot = episodeState.lastResult?.state?.snapshot ?? sim.snapshot();
|
|
59
75
|
const stepEvents = [];
|
|
60
76
|
for (let index = 0; index < options.frameSkip; index += 1) {
|
|
61
77
|
sim.step(FIXED_STEP);
|
|
62
|
-
|
|
78
|
+
const events = typeof sim.consumeStepEvents === 'function'
|
|
79
|
+
? sim.consumeStepEvents()
|
|
80
|
+
: sim.events;
|
|
81
|
+
stepEvents.push(...collectStepEvents(events));
|
|
63
82
|
}
|
|
64
83
|
episodeState.step += 1;
|
|
65
84
|
const result = buildResult({ host, episodeState, events: stepEvents, actionErrors: errors, actions });
|
|
@@ -93,6 +112,22 @@ export function createEnvironmentRuntime(host) {
|
|
|
93
112
|
return { reset, step, getObservation, getState, getActionSpec, getObservationSpec, destroy };
|
|
94
113
|
}
|
|
95
114
|
|
|
115
|
+
function isNoopPitIntent(pitIntent) {
|
|
116
|
+
if (pitIntent && typeof pitIntent === 'object') {
|
|
117
|
+
return Number(pitIntent.intent ?? pitIntent.pitIntent) === 0;
|
|
118
|
+
}
|
|
119
|
+
return Number(pitIntent) === 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function initializeControlledPitIntent(host) {
|
|
123
|
+
const sim = host.getSimulation?.();
|
|
124
|
+
const options = host.getOptions?.();
|
|
125
|
+
options?.controlledDrivers?.forEach?.((driverId) => {
|
|
126
|
+
sim?.setAutomaticPitIntentEnabled?.(driverId, false);
|
|
127
|
+
sim?.setPitIntent?.(driverId, 0);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
96
131
|
function buildResult({ host, episodeState, events, actionErrors, actions = {} }) {
|
|
97
132
|
const options = host.getOptions();
|
|
98
133
|
const snapshot = host.getSimulation().snapshot();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { normalizeAngle } from '../simulation/simMath.js';
|
|
2
|
+
import { offsetTrackPoint, pointAt } from '../simulation/trackModel.js';
|
|
3
|
+
import { kphToSimSpeed, metersToSimUnits, simUnitsToMeters } from '../simulation/units.js';
|
|
4
|
+
|
|
5
|
+
export const ENVIRONMENT_SCENARIO_PRESETS = Object.freeze([
|
|
6
|
+
'cornering',
|
|
7
|
+
'off-track-recovery',
|
|
8
|
+
'overtaking-pack',
|
|
9
|
+
'pit-entry',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export function resolveScenarioPlacementConfig(scenario = {}, driverIds = new Set()) {
|
|
13
|
+
const preset = scenario.preset == null ? null : String(scenario.preset);
|
|
14
|
+
if (preset && !ENVIRONMENT_SCENARIO_PRESETS.includes(preset)) {
|
|
15
|
+
throw new Error(`PaddockJS environment scenario.preset must be one of: ${ENVIRONMENT_SCENARIO_PRESETS.join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const placements = normalizePlacementMap(scenario.placements, driverIds);
|
|
19
|
+
const traffic = normalizeTrafficLayout(scenario.traffic, driverIds);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
preset,
|
|
23
|
+
placements,
|
|
24
|
+
traffic,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function applyEnvironmentScenario(sim, options) {
|
|
29
|
+
const scenario = options.scenario ?? {};
|
|
30
|
+
if (!scenario.preset && Object.keys(scenario.placements ?? {}).length === 0 && (scenario.traffic ?? []).length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const snapshot = sim.snapshot();
|
|
35
|
+
const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
|
|
36
|
+
const placements = {
|
|
37
|
+
...buildPresetPlacements(snapshot, options),
|
|
38
|
+
...(scenario.placements ?? {}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
Object.entries(placements).forEach(([driverId, placement]) => {
|
|
42
|
+
if (!carsById.has(driverId)) return;
|
|
43
|
+
applyPlacement(sim, snapshot.track, carsById.get(driverId), driverId, placement);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const nextSnapshot = sim.snapshot();
|
|
47
|
+
const nextCarsById = new Map(nextSnapshot.cars.map((car) => [car.id, car]));
|
|
48
|
+
scenario.traffic?.forEach((trafficPlacement) => {
|
|
49
|
+
const reference = nextCarsById.get(trafficPlacement.relativeTo);
|
|
50
|
+
const car = nextCarsById.get(trafficPlacement.driverId);
|
|
51
|
+
if (!reference || !car) return;
|
|
52
|
+
applyPlacement(sim, nextSnapshot.track, car, trafficPlacement.driverId, {
|
|
53
|
+
distanceMeters: reference.distanceMeters + trafficPlacement.deltaDistanceMeters,
|
|
54
|
+
offsetMeters: trafficPlacement.offsetMeters,
|
|
55
|
+
speedKph: trafficPlacement.speedKph ?? reference.speedKph,
|
|
56
|
+
headingErrorRadians: trafficPlacement.headingErrorRadians ?? 0,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizePlacementMap(value, driverIds) {
|
|
62
|
+
if (value == null) return {};
|
|
63
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
64
|
+
throw new Error('PaddockJS environment scenario.placements must be an object keyed by driver id.');
|
|
65
|
+
}
|
|
66
|
+
return Object.fromEntries(Object.entries(value).map(([driverId, placement]) => {
|
|
67
|
+
assertKnownDriver(driverId, driverIds, 'scenario placement');
|
|
68
|
+
return [driverId, normalizePlacement(placement, `scenario.placements.${driverId}`)];
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeTrafficLayout(value, driverIds) {
|
|
73
|
+
if (value == null) return [];
|
|
74
|
+
if (!Array.isArray(value)) {
|
|
75
|
+
throw new Error('PaddockJS environment scenario.traffic must be an array.');
|
|
76
|
+
}
|
|
77
|
+
return value.map((entry, index) => {
|
|
78
|
+
if (!entry || typeof entry !== 'object') {
|
|
79
|
+
throw new Error(`PaddockJS environment scenario.traffic[${index}] must be an object.`);
|
|
80
|
+
}
|
|
81
|
+
assertKnownDriver(entry.driverId, driverIds, `scenario.traffic[${index}].driverId`);
|
|
82
|
+
assertKnownDriver(entry.relativeTo, driverIds, `scenario.traffic[${index}].relativeTo`);
|
|
83
|
+
return {
|
|
84
|
+
driverId: entry.driverId,
|
|
85
|
+
relativeTo: entry.relativeTo,
|
|
86
|
+
deltaDistanceMeters: finiteNumber(entry.deltaDistanceMeters, 0, `scenario.traffic[${index}].deltaDistanceMeters`),
|
|
87
|
+
offsetMeters: finiteNumber(entry.offsetMeters, 0, `scenario.traffic[${index}].offsetMeters`),
|
|
88
|
+
speedKph: optionalFiniteNumber(entry.speedKph, `scenario.traffic[${index}].speedKph`),
|
|
89
|
+
headingErrorRadians: finiteNumber(entry.headingErrorRadians, 0, `scenario.traffic[${index}].headingErrorRadians`),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizePlacement(placement, label) {
|
|
95
|
+
if (!placement || typeof placement !== 'object') {
|
|
96
|
+
throw new Error(`PaddockJS environment ${label} must be an object.`);
|
|
97
|
+
}
|
|
98
|
+
const distanceMeters = placement.distanceMeters ?? placement.startDistanceMeters;
|
|
99
|
+
return {
|
|
100
|
+
distanceMeters: optionalFiniteNumber(distanceMeters, `${label}.distanceMeters`),
|
|
101
|
+
offsetMeters: finiteNumber(placement.offsetMeters, 0, `${label}.offsetMeters`),
|
|
102
|
+
speedKph: optionalFiniteNumber(placement.speedKph, `${label}.speedKph`),
|
|
103
|
+
headingErrorRadians: finiteNumber(placement.headingErrorRadians, 0, `${label}.headingErrorRadians`),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildPresetPlacements(snapshot, options) {
|
|
108
|
+
const preset = options.scenario?.preset;
|
|
109
|
+
if (!preset) return {};
|
|
110
|
+
const controlledDriverId = options.controlledDrivers[0];
|
|
111
|
+
const cars = snapshot.cars;
|
|
112
|
+
const track = snapshot.track;
|
|
113
|
+
const primaryDistance = presetBaseDistance(track, preset);
|
|
114
|
+
const placements = {};
|
|
115
|
+
|
|
116
|
+
if (preset === 'cornering') {
|
|
117
|
+
placements[controlledDriverId] = {
|
|
118
|
+
distanceMeters: simUnitsToMeters(primaryDistance),
|
|
119
|
+
offsetMeters: 0,
|
|
120
|
+
speedKph: 135,
|
|
121
|
+
headingErrorRadians: 0,
|
|
122
|
+
};
|
|
123
|
+
} else if (preset === 'off-track-recovery') {
|
|
124
|
+
placements[controlledDriverId] = {
|
|
125
|
+
distanceMeters: simUnitsToMeters(primaryDistance),
|
|
126
|
+
offsetMeters: simUnitsToMeters(track.width / 2) + 7,
|
|
127
|
+
speedKph: 52,
|
|
128
|
+
headingErrorRadians: -0.45,
|
|
129
|
+
};
|
|
130
|
+
} else if (preset === 'pit-entry') {
|
|
131
|
+
placements[controlledDriverId] = {
|
|
132
|
+
distanceMeters: simUnitsToMeters(primaryDistance),
|
|
133
|
+
offsetMeters: 0,
|
|
134
|
+
speedKph: 120,
|
|
135
|
+
headingErrorRadians: 0,
|
|
136
|
+
};
|
|
137
|
+
} else if (preset === 'overtaking-pack') {
|
|
138
|
+
const baseMeters = simUnitsToMeters(primaryDistance);
|
|
139
|
+
cars.slice(0, 4).forEach((car, index) => {
|
|
140
|
+
placements[car.id] = {
|
|
141
|
+
distanceMeters: baseMeters + (index - 1) * 18,
|
|
142
|
+
offsetMeters: index % 2 === 0 ? -2.2 : 2.2,
|
|
143
|
+
speedKph: 135 - index * 3,
|
|
144
|
+
headingErrorRadians: 0,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return placements;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function presetBaseDistance(track, preset) {
|
|
153
|
+
if (preset === 'cornering') return findHighCurvatureDistance(track) - metersToSimUnits(70);
|
|
154
|
+
if (preset === 'off-track-recovery') return track.length * 0.42;
|
|
155
|
+
if (preset === 'overtaking-pack') return track.length * 0.35;
|
|
156
|
+
if (preset === 'pit-entry') {
|
|
157
|
+
const pitDistance = track.pitLane?.entry?.trackDistance ??
|
|
158
|
+
track.pitLane?.entry?.distanceFromStart ??
|
|
159
|
+
track.length * 0.92;
|
|
160
|
+
return pitDistance - metersToSimUnits(170);
|
|
161
|
+
}
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findHighCurvatureDistance(track) {
|
|
166
|
+
const samples = Array.isArray(track.samples) ? track.samples : [];
|
|
167
|
+
const fallback = track.length * 0.25;
|
|
168
|
+
const best = samples
|
|
169
|
+
.filter((sample) => sample.distance > metersToSimUnits(350))
|
|
170
|
+
.reduce((selected, sample) => {
|
|
171
|
+
if (!selected || (sample.curvature ?? 0) > (selected.curvature ?? 0)) return sample;
|
|
172
|
+
return selected;
|
|
173
|
+
}, null);
|
|
174
|
+
return best?.distance ?? fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function applyPlacement(sim, track, currentCar, driverId, placement) {
|
|
178
|
+
const currentDistanceMeters = currentCar.distanceMeters ?? simUnitsToMeters(currentCar.raceDistance ?? currentCar.progress ?? 0);
|
|
179
|
+
const raceDistance = metersToSimUnits(Math.max(0, placement.distanceMeters ?? currentDistanceMeters));
|
|
180
|
+
const offset = metersToSimUnits(placement.offsetMeters ?? 0);
|
|
181
|
+
const base = pointAt(track, raceDistance);
|
|
182
|
+
const position = offsetTrackPoint(base, offset);
|
|
183
|
+
const heading = normalizeAngle(base.heading + (placement.headingErrorRadians ?? 0));
|
|
184
|
+
const partial = {
|
|
185
|
+
x: position.x,
|
|
186
|
+
y: position.y,
|
|
187
|
+
previousX: position.x,
|
|
188
|
+
previousY: position.y,
|
|
189
|
+
progress: base.distance,
|
|
190
|
+
raceDistance,
|
|
191
|
+
desiredOffset: offset,
|
|
192
|
+
heading,
|
|
193
|
+
previousHeading: heading,
|
|
194
|
+
};
|
|
195
|
+
if (placement.speedKph != null) partial.speed = kphToSimSpeed(placement.speedKph);
|
|
196
|
+
sim.setCarState(driverId, partial);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertKnownDriver(driverId, driverIds, label) {
|
|
200
|
+
if (typeof driverId !== 'string' || driverId.length === 0) {
|
|
201
|
+
throw new Error(`PaddockJS environment ${label} must be a driver id.`);
|
|
202
|
+
}
|
|
203
|
+
if (!driverIds.has(driverId)) {
|
|
204
|
+
throw new Error(`PaddockJS environment ${label} does not exist: ${driverId}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function finiteNumber(value, fallback, label) {
|
|
209
|
+
if (value == null) return fallback;
|
|
210
|
+
const number = Number(value);
|
|
211
|
+
if (!Number.isFinite(number)) {
|
|
212
|
+
throw new Error(`PaddockJS environment ${label} must be a finite number.`);
|
|
213
|
+
}
|
|
214
|
+
return number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function optionalFiniteNumber(value, label) {
|
|
218
|
+
if (value == null) return undefined;
|
|
219
|
+
return finiteNumber(value, undefined, label);
|
|
220
|
+
}
|