@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
package/src/app/domBindings.js
CHANGED
|
@@ -6,7 +6,9 @@ export function querySimulatorDom(root) {
|
|
|
6
6
|
startLightsLabel: root.querySelector('[data-start-lights-label]'),
|
|
7
7
|
towerLap: root.querySelector('[data-tower-lap-readout]'),
|
|
8
8
|
towerTotalLaps: root.querySelector('[data-tower-total-laps]'),
|
|
9
|
-
|
|
9
|
+
towerRaceControlBanner: root.querySelector('[data-tower-race-control-banner]'),
|
|
10
|
+
towerRaceControlKicker: root.querySelector('[data-tower-race-control-kicker]'),
|
|
11
|
+
towerRaceControlTitle: root.querySelector('[data-tower-race-control-title]'),
|
|
10
12
|
timingGapLabel: root.querySelector('[data-timing-gap-label]'),
|
|
11
13
|
lap: root.querySelector('[data-lap-readout]'),
|
|
12
14
|
drs: root.querySelector('[data-drs-readout]'),
|
|
@@ -45,13 +47,17 @@ export function querySimulatorDom(root) {
|
|
|
45
47
|
telemetryDrawerWorkbench: root.querySelector('[data-race-telemetry-drawer]'),
|
|
46
48
|
telemetryDrawer: root.querySelector('[data-telemetry-drawer]'),
|
|
47
49
|
telemetryDrawerToggle: root.querySelector('[data-telemetry-drawer-toggle]'),
|
|
48
|
-
|
|
50
|
+
stewardMessage: root.querySelector('[data-steward-message]'),
|
|
51
|
+
stewardMessageKicker: root.querySelector('[data-steward-message-kicker]'),
|
|
52
|
+
stewardMessageTitle: root.querySelector('[data-steward-message-title]'),
|
|
53
|
+
stewardMessageDetail: root.querySelector('[data-steward-message-detail]'),
|
|
49
54
|
raceDataPanel: root.querySelector('[data-race-data-panel]'),
|
|
50
55
|
raceDataKicker: root.querySelector('[data-race-data-kicker]'),
|
|
51
56
|
raceDataTitle: root.querySelector('[data-race-data-title]'),
|
|
52
57
|
raceDataNumber: root.querySelector('[data-race-data-number]'),
|
|
53
58
|
raceDataSubtitle: root.querySelector('[data-race-data-subtitle]'),
|
|
54
59
|
raceDataOpen: root.querySelector('[data-race-data-open]'),
|
|
60
|
+
raceDataDismiss: root.querySelector('[data-race-data-dismiss]'),
|
|
55
61
|
finishPanel: root.querySelector('[data-race-finish-panel]'),
|
|
56
62
|
finishWinner: root.querySelector('[data-race-finish-winner]'),
|
|
57
63
|
finishClassification: root.querySelector('[data-race-finish-classification]'),
|
|
@@ -66,6 +72,8 @@ export function querySimulatorDom(root) {
|
|
|
66
72
|
timingGapModeButtons: root.querySelectorAll('[data-timing-gap-mode]'),
|
|
67
73
|
cameraButtons: root.querySelectorAll('[data-camera-mode]'),
|
|
68
74
|
overviewModeButtons: root.querySelectorAll('[data-overview-mode]'),
|
|
75
|
+
bannerMuteButtons: root.querySelectorAll('[data-race-data-banners-muted]'),
|
|
76
|
+
simulationSpeedButtons: root.querySelectorAll('[data-simulation-speed]'),
|
|
69
77
|
zoomInButton: root.querySelector('[data-zoom-in]'),
|
|
70
78
|
zoomOutButton: root.querySelector('[data-zoom-out]'),
|
|
71
79
|
readouts,
|
|
@@ -13,7 +13,7 @@ export const DEFAULT_F1_SIMULATOR_OPTIONS = {
|
|
|
13
13
|
showBackLink: true,
|
|
14
14
|
ui: {
|
|
15
15
|
layoutPreset: 'standard',
|
|
16
|
-
cameraControls: '
|
|
16
|
+
cameraControls: 'external',
|
|
17
17
|
showFps: true,
|
|
18
18
|
showTimingTower: true,
|
|
19
19
|
showTelemetry: true,
|
|
@@ -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'],
|
|
@@ -45,7 +46,7 @@ export const PADDOCK_SIMULATOR_PRESETS = {
|
|
|
45
46
|
dashboard: {
|
|
46
47
|
ui: {
|
|
47
48
|
layoutPreset: 'standard',
|
|
48
|
-
cameraControls: '
|
|
49
|
+
cameraControls: 'external',
|
|
49
50
|
showFps: true,
|
|
50
51
|
showTimingTower: true,
|
|
51
52
|
showTelemetry: true,
|
|
@@ -73,7 +74,7 @@ export const PADDOCK_SIMULATOR_PRESETS = {
|
|
|
73
74
|
'compact-race': {
|
|
74
75
|
ui: {
|
|
75
76
|
layoutPreset: 'standard',
|
|
76
|
-
cameraControls: '
|
|
77
|
+
cameraControls: 'external',
|
|
77
78
|
showFps: false,
|
|
78
79
|
showTimingTower: false,
|
|
79
80
|
showTelemetry: false,
|
|
@@ -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', 'pit']);
|
|
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,
|
package/src/data/championship.js
CHANGED
|
@@ -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(
|
|
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,68 @@
|
|
|
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
|
+
const pitIntentByDriver = {};
|
|
8
|
+
|
|
9
|
+
controlledDrivers.forEach((driverId) => {
|
|
10
|
+
const action = actions?.[driverId];
|
|
11
|
+
if (!action || typeof action !== 'object') {
|
|
12
|
+
handleActionError(`Missing action for controlled driver: ${driverId}`, policy, errors);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const normalized = normalizeAction(action, driverId, policy, errors);
|
|
16
|
+
if (normalized) controlsByDriver[driverId] = normalized;
|
|
17
|
+
const pitIntent = normalizePitIntentAction(action, driverId, policy, errors);
|
|
18
|
+
if (pitIntent != null) pitIntentByDriver[driverId] = pitIntent;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return { controlsByDriver, pitIntentByDriver, errors };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeAction(action, driverId = 'unknown', policy = 'strict', errors = []) {
|
|
25
|
+
const steering = finiteActionValue(action.steering, `Invalid steering action for controlled driver: ${driverId}`, policy, errors);
|
|
26
|
+
const throttle = finiteActionValue(action.throttle, `Invalid throttle action for controlled driver: ${driverId}`, policy, errors);
|
|
27
|
+
const brake = finiteActionValue(action.brake, `Invalid brake action for controlled driver: ${driverId}`, policy, errors);
|
|
28
|
+
|
|
29
|
+
if (steering == null || throttle == null || brake == null) return null;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
steering: clamp(steering, -1, 1) * VEHICLE_LIMITS.maxSteer,
|
|
33
|
+
throttle: clamp(throttle, 0, 1),
|
|
34
|
+
brake: clamp(brake, 0, 1),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function finiteActionValue(value, message, policy, errors) {
|
|
39
|
+
const number = Number(value ?? 0);
|
|
40
|
+
if (Number.isFinite(number)) return number;
|
|
41
|
+
handleActionError(message, policy, errors);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizePitIntentAction(action, driverId, policy, errors) {
|
|
46
|
+
if (!Object.hasOwn(action, 'pitIntent')) return null;
|
|
47
|
+
const number = Number(action.pitIntent);
|
|
48
|
+
if (Number.isInteger(number) && number >= 0 && number <= 2) {
|
|
49
|
+
const pitCompound = normalizePitCompoundAction(action, driverId, policy, errors);
|
|
50
|
+
if (pitCompound === false) return null;
|
|
51
|
+
return pitCompound == null ? number : { intent: number, targetCompound: pitCompound };
|
|
52
|
+
}
|
|
53
|
+
handleActionError(`Invalid pitIntent action for controlled driver: ${driverId}`, policy, errors);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizePitCompoundAction(action, driverId, policy, errors) {
|
|
58
|
+
if (!Object.hasOwn(action, 'pitCompound') && !Object.hasOwn(action, 'pitTargetCompound')) return null;
|
|
59
|
+
const value = action.pitCompound ?? action.pitTargetCompound;
|
|
60
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
61
|
+
handleActionError(`Invalid pitCompound action for controlled driver: ${driverId}`, policy, errors);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function handleActionError(message, policy, errors) {
|
|
66
|
+
if (policy === 'strict') throw new Error(message);
|
|
67
|
+
errors.push(message);
|
|
68
|
+
}
|
|
@@ -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,184 @@
|
|
|
1
|
+
import { simUnitsToMeters } from '../simulation/units.js';
|
|
2
|
+
import { createPaddockEnvironment } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_EVALUATION_CASES = Object.freeze([
|
|
5
|
+
{
|
|
6
|
+
name: 'cornering',
|
|
7
|
+
seed: 7101,
|
|
8
|
+
trackSeed: 2601,
|
|
9
|
+
maxSteps: 420,
|
|
10
|
+
scenario: { preset: 'cornering' },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'off-track-recovery',
|
|
14
|
+
seed: 7102,
|
|
15
|
+
trackSeed: 2602,
|
|
16
|
+
maxSteps: 360,
|
|
17
|
+
scenario: { preset: 'off-track-recovery' },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'overtaking-pack',
|
|
21
|
+
seed: 7103,
|
|
22
|
+
trackSeed: 2603,
|
|
23
|
+
maxSteps: 600,
|
|
24
|
+
scenario: { preset: 'overtaking-pack' },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'pit-entry',
|
|
28
|
+
seed: 7104,
|
|
29
|
+
trackSeed: 2604,
|
|
30
|
+
maxSteps: 480,
|
|
31
|
+
scenario: { preset: 'pit-entry' },
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function runEnvironmentEvaluation({
|
|
36
|
+
baseOptions,
|
|
37
|
+
policy,
|
|
38
|
+
cases = DEFAULT_EVALUATION_CASES,
|
|
39
|
+
createEnvironment = createPaddockEnvironment,
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (!baseOptions || typeof baseOptions !== 'object') {
|
|
42
|
+
throw new Error('PaddockJS environment evaluation requires baseOptions.');
|
|
43
|
+
}
|
|
44
|
+
if (!policy) {
|
|
45
|
+
throw new Error('PaddockJS environment evaluation requires a policy function or object with predict().');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
cases: cases.map((evaluationCase) => runEvaluationCase({
|
|
50
|
+
baseOptions,
|
|
51
|
+
policy,
|
|
52
|
+
evaluationCase,
|
|
53
|
+
createEnvironment,
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createEvaluationTracker(initialResult) {
|
|
59
|
+
const initialCars = carsById(initialResult.state.snapshot);
|
|
60
|
+
const metrics = Object.fromEntries(initialResult.info.controlledDrivers.map((driverId) => {
|
|
61
|
+
const car = initialCars.get(driverId);
|
|
62
|
+
return [driverId, {
|
|
63
|
+
distanceMeters: 0,
|
|
64
|
+
lapProgressMeters: lapProgressMetersFromSnapshotCar(car),
|
|
65
|
+
offTrackSteps: car && car.surface !== 'track' ? 1 : 0,
|
|
66
|
+
contactCount: 0,
|
|
67
|
+
recoverySuccess: false,
|
|
68
|
+
passCount: 0,
|
|
69
|
+
lapTimeSeconds: null,
|
|
70
|
+
startedOffTrack: Boolean(car && car.surface !== 'track'),
|
|
71
|
+
startPosition: car?.rank ?? null,
|
|
72
|
+
bestPosition: car?.rank ?? null,
|
|
73
|
+
startDistanceMeters: cumulativeDistanceMetersFromSnapshotCar(car),
|
|
74
|
+
}];
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
update(result) {
|
|
79
|
+
const currentCars = carsById(result.state.snapshot);
|
|
80
|
+
result.info.controlledDrivers.forEach((driverId) => {
|
|
81
|
+
const car = currentCars.get(driverId);
|
|
82
|
+
const entry = metrics[driverId];
|
|
83
|
+
if (!car || !entry) return;
|
|
84
|
+
entry.distanceMeters = Math.max(0, cumulativeDistanceMetersFromSnapshotCar(car) - entry.startDistanceMeters);
|
|
85
|
+
entry.lapProgressMeters = lapProgressMetersFromSnapshotCar(car);
|
|
86
|
+
if (car.surface !== 'track') entry.offTrackSteps += 1;
|
|
87
|
+
if (entry.startedOffTrack && car.surface === 'track') entry.recoverySuccess = true;
|
|
88
|
+
if (Number.isFinite(car.rank)) {
|
|
89
|
+
entry.bestPosition = entry.bestPosition == null ? car.rank : Math.min(entry.bestPosition, car.rank);
|
|
90
|
+
entry.passCount = entry.startPosition == null ? 0 : Math.max(0, entry.startPosition - entry.bestPosition);
|
|
91
|
+
}
|
|
92
|
+
const completedLaps = car.lapTelemetry?.completedLaps ?? 0;
|
|
93
|
+
if (completedLaps > 0 && entry.lapTimeSeconds == null) {
|
|
94
|
+
entry.lapTimeSeconds = result.info.elapsedSeconds;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
result.events.forEach((event) => {
|
|
98
|
+
if (event.type !== 'collision') return;
|
|
99
|
+
Object.keys(metrics).forEach((driverId) => {
|
|
100
|
+
if (event.driverId === driverId || event.carId === driverId || event.otherCarId === driverId || event.driverIds?.includes?.(driverId)) {
|
|
101
|
+
metrics[driverId].contactCount += 1;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
finish() {
|
|
107
|
+
return Object.fromEntries(Object.entries(metrics).map(([driverId, entry]) => [
|
|
108
|
+
driverId,
|
|
109
|
+
{
|
|
110
|
+
distanceMeters: entry.distanceMeters,
|
|
111
|
+
lapProgressMeters: entry.lapProgressMeters,
|
|
112
|
+
offTrackSteps: entry.offTrackSteps,
|
|
113
|
+
contactCount: entry.contactCount,
|
|
114
|
+
recoverySuccess: entry.recoverySuccess,
|
|
115
|
+
passCount: entry.passCount,
|
|
116
|
+
lapTimeSeconds: entry.lapTimeSeconds,
|
|
117
|
+
},
|
|
118
|
+
]));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cumulativeDistanceMetersFromSnapshotCar(car) {
|
|
124
|
+
const distance = Number(car?.distanceMeters ?? car?.raceDistanceMeters);
|
|
125
|
+
if (Number.isFinite(distance)) return distance;
|
|
126
|
+
return simUnitsToMeters(car?.raceDistance ?? 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function lapProgressMetersFromSnapshotCar(car) {
|
|
130
|
+
const progressMeters = Number(car?.lapProgressMeters ?? car?.progressMeters);
|
|
131
|
+
if (Number.isFinite(progressMeters)) return progressMeters;
|
|
132
|
+
return simUnitsToMeters(car?.progress ?? 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runEvaluationCase({ baseOptions, policy, evaluationCase, createEnvironment }) {
|
|
136
|
+
const env = createEnvironment({
|
|
137
|
+
...baseOptions,
|
|
138
|
+
seed: evaluationCase.seed ?? baseOptions.seed,
|
|
139
|
+
trackSeed: evaluationCase.trackSeed ?? baseOptions.trackSeed,
|
|
140
|
+
scenario: {
|
|
141
|
+
...(baseOptions.scenario ?? {}),
|
|
142
|
+
...(evaluationCase.scenario ?? {}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
let result = env.reset();
|
|
146
|
+
const tracker = createEvaluationTracker(result);
|
|
147
|
+
const maxSteps = Math.max(1, Math.floor(evaluationCase.maxSteps ?? baseOptions.episode?.maxSteps ?? 1000));
|
|
148
|
+
let steps = 0;
|
|
149
|
+
while (!result.done && steps < maxSteps) {
|
|
150
|
+
const actions = Object.fromEntries(result.info.controlledDrivers.map((driverId) => [
|
|
151
|
+
driverId,
|
|
152
|
+
predictAction(policy, result.observation[driverId], {
|
|
153
|
+
driverId,
|
|
154
|
+
caseName: evaluationCase.name,
|
|
155
|
+
step: steps,
|
|
156
|
+
result,
|
|
157
|
+
}),
|
|
158
|
+
]));
|
|
159
|
+
result = env.step(actions);
|
|
160
|
+
tracker.update(result);
|
|
161
|
+
steps += 1;
|
|
162
|
+
}
|
|
163
|
+
env.destroy?.();
|
|
164
|
+
return {
|
|
165
|
+
name: evaluationCase.name,
|
|
166
|
+
seed: evaluationCase.seed ?? baseOptions.seed,
|
|
167
|
+
trackSeed: evaluationCase.trackSeed ?? baseOptions.trackSeed,
|
|
168
|
+
steps,
|
|
169
|
+
done: result.done,
|
|
170
|
+
endReason: result.info.endReason,
|
|
171
|
+
metrics: tracker.finish(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function predictAction(policy, observation, context) {
|
|
176
|
+
const action = typeof policy === 'function'
|
|
177
|
+
? policy(observation, context)
|
|
178
|
+
: policy.predict(observation, context);
|
|
179
|
+
return action ?? { steering: 0, throttle: 0, brake: 1 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function carsById(snapshot) {
|
|
183
|
+
return new Map(snapshot.cars.map((car) => [car.id, car]));
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|