@inventure71/paddockjs 4.0.0 → 4.2.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 +48 -0
- package/INSTALL_AND_UPDATE.md +14 -1
- package/README.md +33 -1
- package/docs/architecture.md +3 -1
- package/docs/component_inventory.md +5 -4
- package/docs/composable-layouts.md +2 -0
- package/docs/data_contract.md +5 -1
- package/docs/getting-started.md +33 -0
- package/docs/index.md +4 -2
- package/docs/loading.md +141 -0
- package/docs/system_specs.md +15 -2
- package/docs/testing-reliability-guide.md +62 -0
- package/package.json +16 -9
- package/src/data/index.d.ts +64 -1
- package/src/environment/index.d.ts +75 -12
- package/src/index.d.ts +72 -6
- package/src/placeholder/index.d.ts +11 -0
- package/src/placeholder/index.js +61 -0
- package/src/placeholder.css +72 -0
- package/src/simulation/collisionGeometry.js +7 -9
- package/src/simulation/driver/driverController.js +2 -2
- package/src/simulation/driver/edgeRecovery.js +2 -2
- package/src/simulation/driver/racingControls.js +35 -25
- package/src/simulation/driver/racingLinePlan.js +6 -5
- package/src/simulation/driver/rejoinControls.js +3 -3
- package/src/simulation/driver/trafficScan.js +48 -22
- package/src/simulation/participants/participantInteractions.js +24 -15
- package/src/simulation/race/raceStep.js +19 -10
- package/src/simulation/race/safetyCar.js +2 -1
- package/src/simulation/rules/pitLaneSpeedingSteward.js +1 -1
- package/src/simulation/rules/rulesReview.js +19 -9
- package/src/simulation/rules/trackLimitsSteward.js +2 -2
- package/src/simulation/timing/sectorPerformance.js +11 -3
- package/src/simulation/track/spatialQueries.js +80 -11
- package/src/simulation/track/trackModel.js +10 -1
- package/src/simulation/vehicle/contactResolution.js +4 -4
- package/src/simulation/vehicle/vehicleGeometry.js +87 -49
- package/src/simulation/vehicle/vehiclePhysics.js +7 -2
- package/src/simulation/vehicle/vehicleSnapshots.js +5 -2
- package/src/simulation/vehicle/vehicleState.js +3 -2
- package/src/ui/raceCanvasTemplate.js +3 -2
- package/src/ui/raceControlsTemplate.js +1 -0
- package/src/ui/telemetryDrawerTemplate.js +1 -0
- package/src/ui/telemetryTemplates.js +1 -0
- package/examples/train-basic-policy.mjs +0 -276
- package/examples/trainingData.mjs +0 -133
|
@@ -6,6 +6,7 @@ import { generateCenterlineControls, generateFallbackCenterlineControls, generat
|
|
|
6
6
|
import { isValidProceduralTrackModel } from './trackValidation.js';
|
|
7
7
|
import { recalculateSampleGeometry } from './sampleGeometry.js';
|
|
8
8
|
import { rotateSamplesToStandingStart, straightenStandingStartSamples } from './startStraight.js';
|
|
9
|
+
import { prepareSampleIndexLut } from './spatialQueries.js';
|
|
9
10
|
import { createTrackSectors } from './trackSectors.js';
|
|
10
11
|
import { deriveDrsZones, normalizeDrsZone } from './drsZones.js';
|
|
11
12
|
import { createPitLaneModel } from './pitLaneLayout.js';
|
|
@@ -15,7 +16,14 @@ import { attachTrackSnapshotJsonSerializer } from './trackSnapshotJson.js';
|
|
|
15
16
|
|
|
16
17
|
export { WORLD, TRACK } from './trackConstants.js';
|
|
17
18
|
export { isInDrsZone } from './drsZones.js';
|
|
18
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
nearestTrackState,
|
|
21
|
+
offsetTrackPoint,
|
|
22
|
+
pointAt,
|
|
23
|
+
pointAtInto,
|
|
24
|
+
sampleHeadingAt,
|
|
25
|
+
sampleHeadingCurvatureAtInto,
|
|
26
|
+
} from './spatialQueries.js';
|
|
19
27
|
|
|
20
28
|
const PROCEDURAL_TRACK_CACHE = new Map();
|
|
21
29
|
const TRACK_MODEL_CACHE = new WeakMap();
|
|
@@ -57,6 +65,7 @@ export function buildTrackModel(track = TRACK) {
|
|
|
57
65
|
? null
|
|
58
66
|
: createPitLaneModel(model);
|
|
59
67
|
attachTrackQueryIndex(model, createTrackQueryIndex(model));
|
|
68
|
+
prepareSampleIndexLut(model);
|
|
60
69
|
attachTrackSnapshotJsonSerializer(model);
|
|
61
70
|
if (canReuseCachedModel) {
|
|
62
71
|
TRACK_MODEL_CACHE.set(track, freezeTrackModel(model));
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '../collisionGeometry.js';
|
|
7
7
|
import { canCollide, isCollidable } from '../participants/participantInteractions.js';
|
|
8
8
|
import { clamp, normalizeAngle } from '../simMath.js';
|
|
9
|
-
import { VEHICLE_LIMITS } from './vehiclePhysics.js';
|
|
9
|
+
import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from './vehiclePhysics.js';
|
|
10
10
|
import { shiftPreviousRenderPose } from '../pit/pitRouting.js';
|
|
11
11
|
|
|
12
12
|
const MAX_COLLISION_CORRECTION = 4.5;
|
|
@@ -114,8 +114,8 @@ export function resolveCollisionsForSimulation(sim) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (oneCarFixed) {
|
|
117
|
-
if (!firstPitControlled) dampPitContactVelocity(first, 0.985, sim.physicsMode
|
|
118
|
-
if (!secondPitControlled) dampPitContactVelocity(second, 0.985, sim.physicsMode
|
|
117
|
+
if (!firstPitControlled) dampPitContactVelocity(first, 0.985, isSimulatorPhysicsMode(sim.physicsMode));
|
|
118
|
+
if (!secondPitControlled) dampPitContactVelocity(second, 0.985, isSimulatorPhysicsMode(sim.physicsMode));
|
|
119
119
|
} else {
|
|
120
120
|
applyContactVelocityResponse(sim, first, second, collision.axis);
|
|
121
121
|
}
|
|
@@ -167,7 +167,7 @@ export function resolveCollisionsForSimulation(sim) {
|
|
|
167
167
|
export function applyContactVelocityResponse(sim, first, second, axis, options = {}) {
|
|
168
168
|
recordContactVelocityResponse(options.stats);
|
|
169
169
|
if (
|
|
170
|
-
sim.physicsMode
|
|
170
|
+
isSimulatorPhysicsMode(sim.physicsMode) ||
|
|
171
171
|
Number.isFinite(first.velocityX) ||
|
|
172
172
|
Number.isFinite(second.velocityX)
|
|
173
173
|
) {
|
|
@@ -274,33 +274,22 @@ function sweptPoseSignature(pose) {
|
|
|
274
274
|
].join(':');
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
function geometryPose(car) {
|
|
278
|
-
const { x, y, heading } = currentPoseState(car);
|
|
279
|
-
return {
|
|
280
|
-
x,
|
|
281
|
-
y,
|
|
282
|
-
heading,
|
|
283
|
-
previousX: finiteOr(car.previousX, x),
|
|
284
|
-
previousY: finiteOr(car.previousY, y),
|
|
285
|
-
previousHeading: normalizeAngle(finiteOr(car.previousHeading, heading)),
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
277
|
function currentGeometryStateMatches(car, state) {
|
|
290
278
|
const pose = state?.pose;
|
|
291
279
|
if (!pose) return false;
|
|
292
|
-
const currentPose = currentPoseState(car);
|
|
293
280
|
return (
|
|
294
|
-
pose.x ===
|
|
295
|
-
pose.y ===
|
|
296
|
-
pose.heading ===
|
|
281
|
+
pose.x === finiteOr(car.x, 0) &&
|
|
282
|
+
pose.y === finiteOr(car.y, 0) &&
|
|
283
|
+
pose.heading === normalizeAngle(finiteOr(car.heading, 0))
|
|
297
284
|
);
|
|
298
285
|
}
|
|
299
286
|
|
|
300
287
|
function geometryStateMatches(car, state) {
|
|
301
288
|
const pose = state?.pose;
|
|
302
289
|
if (!pose) return false;
|
|
303
|
-
const
|
|
290
|
+
const x = finiteOr(car.x, 0);
|
|
291
|
+
const y = finiteOr(car.y, 0);
|
|
292
|
+
const heading = normalizeAngle(finiteOr(car.heading, 0));
|
|
304
293
|
return (
|
|
305
294
|
pose.x === x &&
|
|
306
295
|
pose.y === y &&
|
|
@@ -312,6 +301,10 @@ function geometryStateMatches(car, state) {
|
|
|
312
301
|
}
|
|
313
302
|
|
|
314
303
|
export function createVehicleShapeAabb(shape) {
|
|
304
|
+
return writeVehicleShapeAabb({}, shape);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function writeVehicleShapeAabb(target, shape) {
|
|
315
308
|
let minX = Infinity;
|
|
316
309
|
let maxX = -Infinity;
|
|
317
310
|
let minY = Infinity;
|
|
@@ -323,12 +316,11 @@ export function createVehicleShapeAabb(shape) {
|
|
|
323
316
|
if (corner.y < minY) minY = corner.y;
|
|
324
317
|
if (corner.y > maxY) maxY = corner.y;
|
|
325
318
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
319
|
+
target.minX = minX;
|
|
320
|
+
target.maxX = maxX;
|
|
321
|
+
target.minY = minY;
|
|
322
|
+
target.maxY = maxY;
|
|
323
|
+
return target;
|
|
332
324
|
}
|
|
333
325
|
|
|
334
326
|
export function mergeAabbs(aabbs) {
|
|
@@ -346,32 +338,73 @@ export function mergeAabbs(aabbs) {
|
|
|
346
338
|
return { minX, maxX, minY, maxY };
|
|
347
339
|
}
|
|
348
340
|
|
|
349
|
-
|
|
350
|
-
const pose =
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
341
|
+
function writeGeometryPose(target, car) {
|
|
342
|
+
const pose = target ?? {};
|
|
343
|
+
const x = finiteOr(car.x, 0);
|
|
344
|
+
const y = finiteOr(car.y, 0);
|
|
345
|
+
const heading = normalizeAngle(finiteOr(car.heading, 0));
|
|
346
|
+
pose.x = x;
|
|
347
|
+
pose.y = y;
|
|
348
|
+
pose.heading = heading;
|
|
349
|
+
pose.previousX = finiteOr(car.previousX, x);
|
|
350
|
+
pose.previousY = finiteOr(car.previousY, y);
|
|
351
|
+
pose.previousHeading = normalizeAngle(finiteOr(car.previousHeading, heading));
|
|
352
|
+
return pose;
|
|
353
|
+
}
|
|
360
354
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
previousBodyAabb,
|
|
373
|
-
sweptBodyAabb,
|
|
355
|
+
function createGeometryStateShell() {
|
|
356
|
+
const state = {
|
|
357
|
+
pose: null,
|
|
358
|
+
current: null,
|
|
359
|
+
previous: null,
|
|
360
|
+
body: null,
|
|
361
|
+
wheels: null,
|
|
362
|
+
contactPatches: null,
|
|
363
|
+
bodyAabb: {},
|
|
364
|
+
previousBodyAabb: {},
|
|
365
|
+
sweptBodyAabb: {},
|
|
374
366
|
};
|
|
367
|
+
// Signatures are derived lazily from the pose: nothing on the hot path reads
|
|
368
|
+
// them, so the per-step string formatting cost must not be paid eagerly.
|
|
369
|
+
Object.defineProperties(state, {
|
|
370
|
+
currentSignature: {
|
|
371
|
+
get() { return currentPoseSignature(this.pose); },
|
|
372
|
+
enumerable: true,
|
|
373
|
+
},
|
|
374
|
+
sweptSignature: {
|
|
375
|
+
get() { return sweptPoseSignature(this.pose); },
|
|
376
|
+
enumerable: true,
|
|
377
|
+
},
|
|
378
|
+
signature: {
|
|
379
|
+
get() { return sweptPoseSignature(this.pose); },
|
|
380
|
+
enumerable: true,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return state;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const PREVIOUS_GEOMETRY_POSE_SCRATCH = { x: 0, y: 0, heading: 0 };
|
|
387
|
+
|
|
388
|
+
export function createVehicleGeometryState(car, target = null) {
|
|
389
|
+
const state = target ?? createGeometryStateShell();
|
|
390
|
+
const pose = state.pose = writeGeometryPose(state.pose, car);
|
|
391
|
+
const current = state.current = writeVehicleGeometry(state.current, pose);
|
|
392
|
+
PREVIOUS_GEOMETRY_POSE_SCRATCH.x = pose.previousX;
|
|
393
|
+
PREVIOUS_GEOMETRY_POSE_SCRATCH.y = pose.previousY;
|
|
394
|
+
PREVIOUS_GEOMETRY_POSE_SCRATCH.heading = pose.previousHeading;
|
|
395
|
+
const previous = state.previous = writeVehicleGeometry(state.previous, PREVIOUS_GEOMETRY_POSE_SCRATCH);
|
|
396
|
+
const bodyAabb = writeVehicleShapeAabb(state.bodyAabb, current.body);
|
|
397
|
+
const previousBodyAabb = writeVehicleShapeAabb(state.previousBodyAabb, previous.body);
|
|
398
|
+
const sweptBodyAabb = state.sweptBodyAabb;
|
|
399
|
+
sweptBodyAabb.minX = Math.min(bodyAabb.minX, previousBodyAabb.minX);
|
|
400
|
+
sweptBodyAabb.maxX = Math.max(bodyAabb.maxX, previousBodyAabb.maxX);
|
|
401
|
+
sweptBodyAabb.minY = Math.min(bodyAabb.minY, previousBodyAabb.minY);
|
|
402
|
+
sweptBodyAabb.maxY = Math.max(bodyAabb.maxY, previousBodyAabb.maxY);
|
|
403
|
+
|
|
404
|
+
state.body = current.body;
|
|
405
|
+
state.wheels = current.wheels;
|
|
406
|
+
state.contactPatches = current.contactPatches;
|
|
407
|
+
return state;
|
|
375
408
|
}
|
|
376
409
|
|
|
377
410
|
export function createCurrentVehicleGeometryState(car, target = null) {
|
|
@@ -388,8 +421,13 @@ export function createCurrentVehicleGeometryState(car, target = null) {
|
|
|
388
421
|
|
|
389
422
|
export function getVehicleGeometryState(car) {
|
|
390
423
|
if (geometryStateMatches(car, car.geometryState)) return car.geometryState;
|
|
391
|
-
|
|
392
|
-
|
|
424
|
+
// Double-buffer pooled states: consumers may still hold the state returned
|
|
425
|
+
// for the previous pose within a step, so alternate between two reusable
|
|
426
|
+
// shells instead of allocating a fresh geometry tree on every pose change.
|
|
427
|
+
const next = createVehicleGeometryState(car, car._geometryStateSpare ?? null);
|
|
428
|
+
car._geometryStateSpare = car.geometryState ?? null;
|
|
429
|
+
car.geometryState = next;
|
|
430
|
+
return next;
|
|
393
431
|
}
|
|
394
432
|
|
|
395
433
|
export function getCurrentVehicleGeometryState(car) {
|
|
@@ -50,7 +50,11 @@ const SIMULATOR_SURFACE_MODEL = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
export function normalizePhysicsMode(value) {
|
|
53
|
-
return value
|
|
53
|
+
return PHYSICS_MODES.includes(value) ? value : DEFAULT_PHYSICS_MODE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isSimulatorPhysicsMode(value) {
|
|
57
|
+
return value === 'advanced';
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
function accelerationLimit(speed, surfaceGrip) {
|
|
@@ -120,7 +124,8 @@ function setPhysicsTelemetry(car, {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
export function integrateVehiclePhysics(car, controls, dt, options = {}) {
|
|
123
|
-
|
|
127
|
+
const mode = normalizePhysicsMode(options.physicsMode);
|
|
128
|
+
if (mode === 'advanced') {
|
|
124
129
|
return integrateSimulatorVehiclePhysics(car, controls, dt, options);
|
|
125
130
|
}
|
|
126
131
|
return integrateArcadeVehiclePhysics(car, controls, dt, options);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
serializeParticipantInteraction,
|
|
3
|
+
serializeParticipantInteractionCached,
|
|
4
|
+
} from '../participants/participantInteractions.js';
|
|
2
5
|
|
|
3
6
|
export function finiteOrNull(value) {
|
|
4
7
|
return Number.isFinite(value) ? value : null;
|
|
@@ -185,7 +188,7 @@ export function serializeRenderCarInto(target, car, dependencies) {
|
|
|
185
188
|
target.dnfOrder = car.dnfOrder ?? null;
|
|
186
189
|
target.outOfRace = Boolean(car.outOfRace || car.destroyed);
|
|
187
190
|
target.pitStop = serializeRenderPitStop(car.pitStop);
|
|
188
|
-
target.interaction =
|
|
191
|
+
target.interaction = serializeParticipantInteractionCached(car);
|
|
189
192
|
return target;
|
|
190
193
|
}
|
|
191
194
|
|
|
@@ -2,7 +2,7 @@ import { buildDriverPersonality } from '../driverController.js';
|
|
|
2
2
|
import { clamp, normalizeAngle, seededRange } from '../simMath.js';
|
|
3
3
|
import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
|
|
4
4
|
import { kphToSimSpeed, metersToSimUnits } from '../units.js';
|
|
5
|
-
import { VEHICLE_LIMITS } from './vehiclePhysics.js';
|
|
5
|
+
import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from './vehiclePhysics.js';
|
|
6
6
|
import { applyWheelSurfaceState } from './wheelSurface.js';
|
|
7
7
|
import { nearestTrackStateForCar } from '../track/trackStatePolicy.js';
|
|
8
8
|
import { clearCarDnf } from '../race/retirements.js';
|
|
@@ -73,6 +73,7 @@ export function createCar(driver, index, random, track, { standingStart = false,
|
|
|
73
73
|
downforceCoefficient: vehicle.downforceCoefficient ?? 6.1 + seededRange(random, -0.18, 0.18),
|
|
74
74
|
tireGrip: vehicle.tireGrip ?? 2.22 + racecraft * 0.28 + seededRange(random, -0.03, 0.03),
|
|
75
75
|
tireCare: vehicle.tireCare ?? 1,
|
|
76
|
+
driverModel: driver.driverModel ?? vehicle.driverModel ?? null,
|
|
76
77
|
pace,
|
|
77
78
|
racecraft,
|
|
78
79
|
personality,
|
|
@@ -170,7 +171,7 @@ export function applyExternalCarState(car, partial, context) {
|
|
|
170
171
|
}
|
|
171
172
|
car.speed = clamp(Number.isFinite(car.speed) ? car.speed : 0, 0, VEHICLE_LIMITS.maxSpeed);
|
|
172
173
|
car.heading = normalizeAngle(Number.isFinite(car.heading) ? car.heading : 0);
|
|
173
|
-
syncExternalVelocityState(car, nextPartial, physicsMode
|
|
174
|
+
syncExternalVelocityState(car, nextPartial, isSimulatorPhysicsMode(physicsMode));
|
|
174
175
|
const centerState = nearestTrackStateForCar(track, car, car, car.progress ?? car.raceDistance);
|
|
175
176
|
applyWheelSurfaceState(car, track, { centerState });
|
|
176
177
|
if (
|
|
@@ -39,8 +39,9 @@ export function createRaceCanvasMarkup({
|
|
|
39
39
|
}
|
|
40
40
|
const timingPanelId = includeTimingTower ? createTimingPanelId() : '';
|
|
41
41
|
const showPhysicsModeIndicator = debug.physicsModeIndicator === true;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
42
|
+
const simulatorPhysics = physicsMode === 'advanced';
|
|
43
|
+
const physicsModeLabel = `${simulatorPhysics ? 'Advanced' : 'Arcade'} physics mode`;
|
|
44
|
+
const physicsModeClass = simulatorPhysics ? 'advanced' : 'arcade';
|
|
44
45
|
|
|
45
46
|
const body = `
|
|
46
47
|
<div class="track-canvas" data-track-canvas></div>
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { createPaddockEnvironment, createProgressReward } from '@inventure71/paddockjs/environment';
|
|
4
|
-
import { TRAINING_DEMO_DRIVERS, TRAINING_DEMO_ENTRIES } from './trainingData.mjs';
|
|
5
|
-
|
|
6
|
-
const DEFAULT_OPTIONS = Object.freeze({
|
|
7
|
-
generations: 4,
|
|
8
|
-
candidates: 5,
|
|
9
|
-
episodes: 1,
|
|
10
|
-
steps: 240,
|
|
11
|
-
frameSkip: 4,
|
|
12
|
-
seed: 71,
|
|
13
|
-
traffic: false,
|
|
14
|
-
evalOnly: false,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const INITIAL_POLICY = Object.freeze({
|
|
18
|
-
steeringHeading: -1.2,
|
|
19
|
-
steeringOffset: -0.35,
|
|
20
|
-
steeringRayBalance: 0.45,
|
|
21
|
-
throttleBias: 0.72,
|
|
22
|
-
throttleFrontClearance: 0.28,
|
|
23
|
-
throttleSpeedDamping: 0.18,
|
|
24
|
-
brakeBias: 0,
|
|
25
|
-
brakeFrontDanger: 0.65,
|
|
26
|
-
brakeOffTrack: 0.2,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const POLICY_KEYS = Object.keys(INITIAL_POLICY);
|
|
30
|
-
|
|
31
|
-
main();
|
|
32
|
-
|
|
33
|
-
function main() {
|
|
34
|
-
const options = parseCliArgs(process.argv.slice(2));
|
|
35
|
-
if (options.help) {
|
|
36
|
-
printHelp();
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const driverId = TRAINING_DEMO_DRIVERS[0].id;
|
|
41
|
-
const rng = createSeededRandom(options.seed);
|
|
42
|
-
const reward = createProgressReward();
|
|
43
|
-
const evaluate = (policy, seedOffset = 0) => evaluatePolicy({
|
|
44
|
-
policy,
|
|
45
|
-
driverId,
|
|
46
|
-
reward,
|
|
47
|
-
options,
|
|
48
|
-
seedOffset,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
if (options.evalOnly) {
|
|
52
|
-
const baseline = evaluate(INITIAL_POLICY);
|
|
53
|
-
printSummary({ options, driverId, bestPolicy: INITIAL_POLICY, best: baseline });
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let bestPolicy = { ...INITIAL_POLICY };
|
|
58
|
-
let best = evaluate(bestPolicy);
|
|
59
|
-
|
|
60
|
-
console.log(formatGenerationLine(0, best));
|
|
61
|
-
|
|
62
|
-
for (let generation = 1; generation <= options.generations; generation += 1) {
|
|
63
|
-
const mutationScale = 0.45 / Math.sqrt(generation);
|
|
64
|
-
const candidates = [{ policy: bestPolicy, scoreSeedOffset: generation * 1000 }];
|
|
65
|
-
|
|
66
|
-
for (let index = 1; index < options.candidates; index += 1) {
|
|
67
|
-
candidates.push({
|
|
68
|
-
policy: mutatePolicy(bestPolicy, rng, mutationScale),
|
|
69
|
-
scoreSeedOffset: generation * 1000 + index * 100,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
for (const candidate of candidates) {
|
|
74
|
-
const score = evaluate(candidate.policy, candidate.scoreSeedOffset);
|
|
75
|
-
if (score.averageReward > best.averageReward) {
|
|
76
|
-
best = score;
|
|
77
|
-
bestPolicy = candidate.policy;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
console.log(formatGenerationLine(generation, best));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
printSummary({ options, driverId, bestPolicy, best });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function evaluatePolicy({ policy, driverId, reward, options, seedOffset = 0 }) {
|
|
88
|
-
let totalReward = 0;
|
|
89
|
-
let totalDistanceMeters = 0;
|
|
90
|
-
let totalCollisions = 0;
|
|
91
|
-
let totalOffTrackSteps = 0;
|
|
92
|
-
let completedEpisodes = 0;
|
|
93
|
-
|
|
94
|
-
for (let episode = 0; episode < options.episodes; episode += 1) {
|
|
95
|
-
const env = createTrainingEnvironment({ driverId, reward, options, episode, seedOffset });
|
|
96
|
-
let result = env.reset();
|
|
97
|
-
let episodeReward = 0;
|
|
98
|
-
|
|
99
|
-
for (let step = 0; step < options.steps && !result.done; step += 1) {
|
|
100
|
-
const observation = result.observation[driverId];
|
|
101
|
-
const action = chooseAction(policy, observation);
|
|
102
|
-
result = env.step({ [driverId]: action });
|
|
103
|
-
episodeReward += result.reward?.[driverId] ?? 0;
|
|
104
|
-
totalCollisions += result.events.filter((event) => event.type === 'collision').length;
|
|
105
|
-
if (result.observation[driverId]?.object?.self?.onTrack === false) totalOffTrackSteps += 1;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const car = result.state.snapshot.cars.find((entry) => entry.id === driverId);
|
|
109
|
-
totalReward += episodeReward;
|
|
110
|
-
totalDistanceMeters += Number(car?.distanceMeters ?? 0);
|
|
111
|
-
completedEpisodes += 1;
|
|
112
|
-
env.destroy();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
averageReward: totalReward / Math.max(1, completedEpisodes),
|
|
117
|
-
averageDistanceMeters: totalDistanceMeters / Math.max(1, completedEpisodes),
|
|
118
|
-
collisions: totalCollisions,
|
|
119
|
-
offTrackSteps: totalOffTrackSteps,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function createTrainingEnvironment({ driverId, reward, options, episode, seedOffset }) {
|
|
124
|
-
const drivers = options.traffic ? TRAINING_DEMO_DRIVERS.slice(0, 8) : TRAINING_DEMO_DRIVERS.slice(0, 1);
|
|
125
|
-
return createPaddockEnvironment({
|
|
126
|
-
drivers,
|
|
127
|
-
entries: TRAINING_DEMO_ENTRIES,
|
|
128
|
-
controlledDrivers: [driverId],
|
|
129
|
-
seed: options.seed + seedOffset + episode,
|
|
130
|
-
trackSeed: options.seed + 2026 + seedOffset + episode,
|
|
131
|
-
totalLaps: 3,
|
|
132
|
-
frameSkip: options.frameSkip,
|
|
133
|
-
scenario: {
|
|
134
|
-
participants: options.traffic ? 'all' : 'controlled-only',
|
|
135
|
-
nonControlled: 'ai',
|
|
136
|
-
},
|
|
137
|
-
episode: {
|
|
138
|
-
maxSteps: options.steps,
|
|
139
|
-
endOnRaceFinish: true,
|
|
140
|
-
},
|
|
141
|
-
rules: {
|
|
142
|
-
standingStart: false,
|
|
143
|
-
},
|
|
144
|
-
reward,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function chooseAction(policy, observation) {
|
|
149
|
-
const self = observation.object.self;
|
|
150
|
-
const frontRay = getRayDistance(observation, 0);
|
|
151
|
-
const leftRay = getRayDistance(observation, -60);
|
|
152
|
-
const rightRay = getRayDistance(observation, 60);
|
|
153
|
-
const frontDanger = clamp01((35 - frontRay) / 35);
|
|
154
|
-
const rayBalanceMeters = rightRay - leftRay;
|
|
155
|
-
|
|
156
|
-
const steering = clamp(
|
|
157
|
-
policy.steeringHeading * self.trackHeadingErrorRadians +
|
|
158
|
-
policy.steeringOffset * (self.trackOffsetMeters / 12) +
|
|
159
|
-
policy.steeringRayBalance * (rayBalanceMeters / 120),
|
|
160
|
-
-1,
|
|
161
|
-
1,
|
|
162
|
-
);
|
|
163
|
-
const throttle = clamp01(
|
|
164
|
-
policy.throttleBias +
|
|
165
|
-
policy.throttleFrontClearance * (frontRay / 120) -
|
|
166
|
-
policy.throttleSpeedDamping * (self.speedKph / 320) -
|
|
167
|
-
(self.onTrack ? 0 : 0.35),
|
|
168
|
-
);
|
|
169
|
-
const brake = clamp01(
|
|
170
|
-
policy.brakeBias +
|
|
171
|
-
policy.brakeFrontDanger * frontDanger +
|
|
172
|
-
(self.onTrack ? 0 : policy.brakeOffTrack),
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
return { steering, throttle, brake };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function getRayDistance(observation, angleDegrees) {
|
|
179
|
-
const ray = observation.object.rays.find((entry) => entry.angleDegrees === angleDegrees);
|
|
180
|
-
return Number(ray?.track?.distanceMeters ?? ray?.lengthMeters ?? 120);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function mutatePolicy(policy, rng, scale) {
|
|
184
|
-
return Object.fromEntries(POLICY_KEYS.map((key) => [
|
|
185
|
-
key,
|
|
186
|
-
policy[key] + randomNormal(rng) * scale,
|
|
187
|
-
]));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function parseCliArgs(argv) {
|
|
191
|
-
const options = { ...DEFAULT_OPTIONS };
|
|
192
|
-
|
|
193
|
-
for (const arg of argv) {
|
|
194
|
-
const [name, rawValue] = arg.split('=');
|
|
195
|
-
if (name === '--help' || name === '-h') options.help = true;
|
|
196
|
-
else if (name === '--eval-only') options.evalOnly = true;
|
|
197
|
-
else if (name === '--traffic') options.traffic = true;
|
|
198
|
-
else if (name === '--no-traffic') options.traffic = false;
|
|
199
|
-
else if (name === '--generations') options.generations = parsePositiveInt(rawValue, name);
|
|
200
|
-
else if (name === '--candidates') options.candidates = parsePositiveInt(rawValue, name);
|
|
201
|
-
else if (name === '--episodes') options.episodes = parsePositiveInt(rawValue, name);
|
|
202
|
-
else if (name === '--steps') options.steps = parsePositiveInt(rawValue, name);
|
|
203
|
-
else if (name === '--frame-skip') options.frameSkip = parsePositiveInt(rawValue, name);
|
|
204
|
-
else if (name === '--seed') options.seed = parsePositiveInt(rawValue, name);
|
|
205
|
-
else throw new Error(`Unknown option: ${name}`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return options;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function parsePositiveInt(value, optionName) {
|
|
212
|
-
const parsed = Number.parseInt(value, 10);
|
|
213
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
214
|
-
throw new Error(`${optionName} requires a positive integer value.`);
|
|
215
|
-
}
|
|
216
|
-
return parsed;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function createSeededRandom(seed) {
|
|
220
|
-
let state = seed >>> 0;
|
|
221
|
-
return function random() {
|
|
222
|
-
state += 0x6D2B79F5;
|
|
223
|
-
let value = state;
|
|
224
|
-
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
225
|
-
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
226
|
-
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function randomNormal(rng) {
|
|
231
|
-
const a = Math.max(Number.EPSILON, rng());
|
|
232
|
-
const b = Math.max(Number.EPSILON, rng());
|
|
233
|
-
return Math.sqrt(-2 * Math.log(a)) * Math.cos(2 * Math.PI * b);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function clamp(value, min, max) {
|
|
237
|
-
return Math.min(max, Math.max(min, value));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function clamp01(value) {
|
|
241
|
-
return clamp(value, 0, 1);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function formatGenerationLine(generation, score) {
|
|
245
|
-
return [
|
|
246
|
-
`generation=${generation}`,
|
|
247
|
-
`reward=${score.averageReward.toFixed(2)}`,
|
|
248
|
-
`distanceMeters=${score.averageDistanceMeters.toFixed(1)}`,
|
|
249
|
-
`collisions=${score.collisions}`,
|
|
250
|
-
`offTrackSteps=${score.offTrackSteps}`,
|
|
251
|
-
].join(' ');
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function printSummary({ options, driverId, bestPolicy, best }) {
|
|
255
|
-
console.log(JSON.stringify({
|
|
256
|
-
driverId,
|
|
257
|
-
options,
|
|
258
|
-
best,
|
|
259
|
-
bestPolicy,
|
|
260
|
-
}, null, 2));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function printHelp() {
|
|
264
|
-
console.log(`Usage: node examples/train-basic-policy.mjs [options]
|
|
265
|
-
|
|
266
|
-
Options:
|
|
267
|
-
--generations=N Random-search generations. Default: ${DEFAULT_OPTIONS.generations}
|
|
268
|
-
--candidates=N Candidate policies per generation. Default: ${DEFAULT_OPTIONS.candidates}
|
|
269
|
-
--episodes=N Episodes per policy evaluation. Default: ${DEFAULT_OPTIONS.episodes}
|
|
270
|
-
--steps=N Maximum manual environment steps per episode. Default: ${DEFAULT_OPTIONS.steps}
|
|
271
|
-
--frame-skip=N Simulator frames advanced by each step(action). Default: ${DEFAULT_OPTIONS.frameSkip}
|
|
272
|
-
--seed=N Deterministic random seed. Default: ${DEFAULT_OPTIONS.seed}
|
|
273
|
-
--traffic Keep built-in AI traffic on track during training.
|
|
274
|
-
--eval-only Evaluate the starter policy without training.
|
|
275
|
-
`);
|
|
276
|
-
}
|