@inventure71/paddockjs 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,7 +54,6 @@ const simulator = createPaddockSimulator({
54
54
  simulator.mountRaceCanvas(canvasRoot, {
55
55
  includeRaceDataPanel: true,
56
56
  includeTimingTower: true,
57
- includeTelemetrySectorBanner: true,
58
57
  timingTowerVerticalFit: 'scroll',
59
58
  });
60
59
  await simulator.start();
@@ -75,11 +74,239 @@ simulator.mountTelemetryLapTimes(lapTimesRoot);
75
74
  simulator.mountTelemetrySectorTimes(sectorTimesRoot);
76
75
  simulator.mountRaceTelemetryDrawer(raceWorkbenchRoot, {
77
76
  timingTowerVerticalFit: 'expand-race-view',
77
+ raceDataTelemetryDetail: true,
78
78
  });
79
79
  simulator.mountCarDriverOverview(overviewRoot);
80
80
  simulator.mountRaceDataPanel(raceDataRoot);
81
81
  ```
82
82
 
83
+ ## Expert Environment Contract
84
+
85
+ Headless training code imports the browser-free environment subpath:
86
+
87
+ ```js
88
+ import { createPaddockEnvironment, createProgressReward } from '@inventure71/paddockjs/environment';
89
+
90
+ const env = createPaddockEnvironment({
91
+ drivers,
92
+ entries,
93
+ controlledDrivers: ['budget'],
94
+ seed: 71,
95
+ trackSeed: 2026,
96
+ totalLaps: 3,
97
+ frameSkip: 2,
98
+ scenario: {
99
+ participants: 'all',
100
+ nonControlled: 'ai',
101
+ },
102
+ reward: createProgressReward(),
103
+ });
104
+
105
+ let result = env.reset();
106
+ result = env.step({
107
+ budget: { steering: 0, throttle: 1, brake: 0 },
108
+ });
109
+ ```
110
+
111
+ `controlledDrivers` is required. It supports one or many externally controlled cars. Non-controlled participants use the built-in driver AI in the first `0.3.0` environment slice.
112
+
113
+ First-slice scenario support:
114
+
115
+ ```js
116
+ scenario: {
117
+ participants: 'all' | 'controlled-only' | ['budget', 'alpha'],
118
+ nonControlled: 'ai',
119
+ }
120
+ ```
121
+
122
+ Static obstacles, custom placements, ghost cars, debug mutation, assisted controls, and Python Gymnasium wrappers are intentionally deferred.
123
+
124
+ Actions use normalized low-level controls:
125
+
126
+ ```js
127
+ {
128
+ budget: {
129
+ steering: -1, // full left
130
+ throttle: 1,
131
+ brake: 0,
132
+ },
133
+ }
134
+ ```
135
+
136
+ `steering` maps to the simulator's internal steering limit. `throttle` and `brake` are clamped from `0` to `1`.
137
+
138
+ The recommended policy convention is `policy.predict(driverObservation) -> { steering, throttle, brake }`. This is a convention, not a base class. Users can wrap any model or algorithm behind that shape.
139
+
140
+ The environment exposes specs for external training code:
141
+
142
+ ```js
143
+ const actionSpec = env.getActionSpec();
144
+ const observationSpec = env.getObservationSpec();
145
+ ```
146
+
147
+ `actionSpec` describes controlled drivers and normalized action ranges. `observationSpec` describes object observation fields, ray layout, nearby-car limits, and vector schema.
148
+
149
+ `createProgressReward()` is a starter reward helper, not the only reward contract. It returns a callback compatible with `reward(context)` and combines:
150
+
151
+ - forward progress in meters from `state.snapshot.cars[].distanceMeters`
152
+ - on-track speed in `current.object.self.speedKph`
153
+ - off-track penalty from `current.object.self.onTrack`
154
+ - collision penalty from global/per-driver step events
155
+ - steering and brake smoothness penalties from the submitted normalized action
156
+
157
+ Weights are explicit and overridable:
158
+
159
+ ```js
160
+ const reward = createProgressReward({
161
+ weights: {
162
+ progress: 1,
163
+ speed: 0.01,
164
+ offTrack: -2,
165
+ collision: -10,
166
+ steering: -0.03,
167
+ brake: -0.02,
168
+ },
169
+ });
170
+ ```
171
+
172
+ Custom reward functions receive the same context:
173
+
174
+ ```js
175
+ reward({ driverId, previous, current, action, events, state }) {
176
+ return current.object.self.speedKph / 100;
177
+ }
178
+ ```
179
+
180
+ `step(actions)` returns a JavaScript object designed for environment loops:
181
+
182
+ ```js
183
+ {
184
+ observation: {
185
+ budget: {
186
+ object: {
187
+ self: {
188
+ speedKph,
189
+ speedMetersPerSecond,
190
+ headingRadians,
191
+ steeringAngleRadians,
192
+ throttle,
193
+ brake,
194
+ lap,
195
+ completedLaps,
196
+ lapProgressMeters,
197
+ trackOffsetMeters,
198
+ trackHeadingErrorRadians,
199
+ onTrack,
200
+ surface,
201
+ tireEnergy,
202
+ },
203
+ race: {
204
+ position,
205
+ totalCars,
206
+ raceMode,
207
+ totalLaps,
208
+ },
209
+ rays,
210
+ nearbyCars,
211
+ events,
212
+ },
213
+ vector,
214
+ schema,
215
+ events,
216
+ },
217
+ },
218
+ reward,
219
+ terminated,
220
+ truncated,
221
+ done,
222
+ events,
223
+ state: {
224
+ snapshot,
225
+ },
226
+ info: {
227
+ step,
228
+ elapsedSeconds,
229
+ seed,
230
+ trackSeed,
231
+ controlledDrivers,
232
+ actionErrors,
233
+ endReason,
234
+ },
235
+ }
236
+ ```
237
+
238
+ Observation objects use physical units such as kph, meters/second, meters, and radians. Optional `vector` values use fixed documented scaling from `schema`; they do not use hidden per-car normalization. Full simulator truth remains available under `state.snapshot`.
239
+
240
+ Default rays use a compact center-origin set with forward, side, and rear awareness:
241
+
242
+ ```js
243
+ [-135, -60, -20, 0, 20, 60, 135, 180]
244
+ ```
245
+
246
+ Track distances are sampled against the actual track model. When the ray starts inside the track border, `track.kind: 'exit'` means the distance is where the ray leaves the valid road. When the ray starts outside the track border, `track.kind: 'entry'` means the distance is where the ray re-enters the valid road. If no track transition is visible within the ray length, `track.hit` is `false`, `track.distanceMeters` equals `lengthMeters`, and `track.kind` is `null`. Car hits require the ray to intersect the other car's footprint and also return max distance when no car is visible:
247
+
248
+ ```js
249
+ {
250
+ angleDegrees,
251
+ angleRadians,
252
+ lengthMeters,
253
+ track: {
254
+ hit,
255
+ distanceMeters,
256
+ kind, // 'exit' | 'entry' | null
257
+ },
258
+ car: {
259
+ hit,
260
+ distanceMeters,
261
+ driverId,
262
+ relativeSpeedKph,
263
+ },
264
+ }
265
+ ```
266
+
267
+ Nearby-car observations are car-relative:
268
+
269
+ ```js
270
+ {
271
+ id,
272
+ relativeForwardMeters,
273
+ relativeRightMeters,
274
+ relativeDistanceMeters,
275
+ relativeSpeedKph,
276
+ relativeHeadingRadians,
277
+ ahead,
278
+ sameLap,
279
+ }
280
+ ```
281
+
282
+ If no `reward` callback is provided, `result.reward` is `null`. If provided, rewards are returned by controlled driver ID.
283
+
284
+ Browser expert mode is opt-in through the browser mount API:
285
+
286
+ ```js
287
+ const simulator = await mountF1Simulator(root, {
288
+ drivers,
289
+ entries,
290
+ expert: {
291
+ enabled: true,
292
+ controlledDrivers: ['budget'],
293
+ frameSkip: 4,
294
+ visualizeSensors: {
295
+ rays: true,
296
+ },
297
+ },
298
+ });
299
+
300
+ simulator.expert.reset();
301
+ simulator.expert.step({
302
+ budget: { steering: 0, throttle: 1, brake: 0 },
303
+ });
304
+ ```
305
+
306
+ When browser expert mode is enabled, automatic ticker simulation advancement is disabled. The visual canvas advances only when host code calls `simulator.expert.reset()` or `simulator.expert.step(actions)`. Browser expert mode is a mount-time option; `restart(nextOptions)` rejects `expert` changes. Destroy and mount a new simulator to switch between built-in ticker control and expert stepping.
307
+
308
+ `expert.visualizeSensors` is a browser-only visual debugging option. When `visualizeSensors: true` or `visualizeSensors: { rays: true }` is set, the simulator draws the controlled drivers' ray sensors in the Pixi world layer from the cars' current positions. Ray lengths and hit markers are based on the same observation data returned from the expert environment result.
309
+
83
310
  ## Lap Telemetry Snapshot
84
311
 
85
312
  Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timing, plus sector performance classes used by package UI:
@@ -109,9 +336,13 @@ Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timin
109
336
 
110
337
  ## Required Options
111
338
 
112
- `drivers` is required and must be a non-empty array.
339
+ `drivers` is required and must be a non-empty array. Driver `id` values must be unique.
340
+
341
+ `totalLaps` is optional. Values are normalized to a finite positive integer, with invalid or non-positive input falling back to a one-lap race.
342
+
343
+ `trackSeed` is optional. If omitted, each mounted browser simulator creates a fresh procedural circuit. Passing a `trackSeed` makes the track deterministic; repeated procedural seeds are cached so multiple mounts can reuse the same generated track definition. Calling `restart({ trackSeed })` rebuilds the simulation on the deterministic circuit for that seed.
113
344
 
114
- `trackSeed` is optional. If omitted, each mounted browser simulator creates a fresh procedural circuit. Passing a `trackSeed` makes the track deterministic; repeated procedural seeds are cached so multiple mounts can reuse the same generated track definition.
345
+ `initialCameraMode` is optional and accepts `'overview'`, `'leader'`, `'selected'`, or `'show-all'`. Invalid values fall back to `'leader'`.
115
346
 
116
347
  Each driver must have:
117
348
 
@@ -197,7 +428,7 @@ Entries are optional. If omitted, defaults are used.
197
428
  }
198
429
  ```
199
430
 
200
- Entries match drivers by `driverId`.
431
+ Entries match drivers by `driverId`. Entry `driverId` values must be unique. `driverNumber` is optional; if omitted, PaddockJS falls back to stable grid order. When `driverNumber` is provided, values must be unique.
201
432
  The car/driver overview primarily renders the existing driver and vehicle rating components from `driver` and `vehicle`. `team` is optional team-level metadata for race identity and future pit behavior; `color` defaults to the driver/car color, and `icon` defaults from the team name or timing code. The timing tower uses the team icon in the car/team column. `driver.customFields`, `vehicle.customFields`, and top-level driver `customFields` are accepted as extra metadata after those defined components.
202
433
 
203
434
  ## Rating Rules
@@ -302,6 +533,7 @@ ui: {
302
533
  enabled: ['project', 'radio'],
303
534
  },
304
535
  raceDataBannerSize: 'custom',
536
+ raceDataTelemetryDetail: false,
305
537
  timingTowerVerticalFit: 'expand-race-view',
306
538
  }
307
539
  ```
@@ -316,6 +548,7 @@ ui: {
316
548
  - `raceDataBanners.initial`: `'project'`, `'radio'`, or `'hidden'`. This controls which lower-third appears first in the precombined shell.
317
549
  - `raceDataBanners.enabled`: array containing `'project'` and/or `'radio'`. Disabled banner types never appear, including after driver selection.
318
550
  - `raceDataBannerSize`: `'custom'` preserves the default lower-third geometry and exposes package CSS variables for host tuning. `'auto'` uses the race space to the right of the timing board when there is enough room and falls back to full lower-third overlap when there is not.
551
+ - `raceDataTelemetryDetail`: when `true`, the project lower-third includes compact S1/S2/S3 sector progress and timing readouts. Radio mode keeps the normal quote layout. The separate `mountTelemetrySectorBanner()` component remains available for hosts that explicitly want an independent sector banner.
319
552
  - `timingTowerVerticalFit`: `'expand-race-view'` lets the combined race window grow to contain the timing tower. `'scroll'` keeps the race window height and scrolls the timing list inside the cropped tower. The same values can be passed to `mountRaceCanvas(root, { includeTimingTower: true, timingTowerVerticalFit })` for an embedded composable timing tower.
320
553
 
321
554
  No UI option exists for raw timing-tower width, max width, or horizontal ratio. The timing tower is capped by the package CSS variable `--timing-board-max-width` because very wide timing boards read poorly. Host pages can scale the whole simulator by changing the mount container, but package-owned layout presets keep their internal proportions inside PaddockJS. For standalone timing towers, give the mount root a fixed height when a fixed vertical footprint is needed; the package keeps the frame inside that height and scrolls only the timing entries. Narrow hosts are handled internally: side-gutter timing towers become stacked/full-width, embedded timing towers stop behaving like desktop side overlays, and the camera safe area stops reserving a left gutter when the measured timing board is effectively full-width.
@@ -401,6 +634,7 @@ The simulation keeps its internal physics in simulator units. Public speed and d
401
634
  - `simUnitsToMeters(simUnits)`
402
635
  - `metersToSimUnits(meters)`
403
636
  - `simSpeedToKph(simUnitsPerSecond)`
637
+ - `simSpeedToMetersPerSecond(simUnitsPerSecond)`
404
638
  - `kphToSimSpeed(kph)`
405
639
 
406
640
  The current calibrated speed scale maps `VEHICLE_LIMITS.maxSpeed` to an F1-like `330 km/h`. Rendered car sprite size remains a visual scale and is intentionally larger than physical car length for readability.
@@ -414,7 +648,7 @@ const simulator = await mountF1Simulator(root, options);
414
648
  Controller methods:
415
649
 
416
650
  - `destroy()`: removes listeners, destroys PixiJS runtime, clears the host root.
417
- - `restart(nextOptions)`: restarts the simulation with merged options.
651
+ - `restart(nextOptions)`: restarts the simulation with merged non-asset options. Use `destroy()` and mount a new simulator to change bundled or host-provided asset URLs.
418
652
  - `selectDriver(driverId)`: selects and focuses a driver.
419
653
  - `setSafetyCarDeployed(deployed)`: toggles safety car state.
420
654
  - `callSafetyCar()`: deploys the safety car.
@@ -428,13 +662,13 @@ Composable controllers additionally expose:
428
662
  - `mountCameraControls(root)`: renders package-owned camera mode and zoom controls outside the race canvas.
429
663
  - `mountSafetyCarControl(root)`: renders a package-owned safety-car button that binds to the same race-control state as other safety buttons.
430
664
  - `mountTimingTower(root)`: renders the timing tower component.
431
- - `mountRaceCanvas(root, { includeRaceDataPanel, includeTimingTower, timingTowerVerticalFit })`: renders the PixiJS canvas host, optional FPS, start lights, and optionally embedded camera controls. Pass `includeRaceDataPanel: true` to place the project/radio lower-third inside the race window so it shares race-canvas clipping and layering. Pass `includeTimingTower: true` to place the timing tower inside the race canvas; `timingTowerVerticalFit: 'expand-race-view'` grows the canvas to the tower height, while `'scroll'` keeps the canvas height and scrolls timing rows inside the tower frame. This is required before `start()`.
665
+ - `mountRaceCanvas(root, { includeRaceDataPanel, includeTimingTower, includeTelemetrySectorBanner, timingTowerVerticalFit })`: renders the PixiJS canvas host, optional FPS, start lights, and optionally embedded camera controls. Pass `includeRaceDataPanel: true` to place the project/radio lower-third inside the race window so it shares race-canvas clipping and layering. Pass `includeTelemetrySectorBanner: true` only when the host intentionally wants the independent sector lower-third in addition to the project/radio banner. Pass `includeTimingTower: true` to place the timing tower inside the race canvas; `timingTowerVerticalFit: 'expand-race-view'` grows the canvas to the tower height, while `'scroll'` keeps the canvas height and scrolls timing rows inside the tower frame. This is required before `start()`.
432
666
  - `mountTelemetryPanel(root, { includeOverview })`: renders the package-owned telemetry stack template. The stack is only a composition of detached telemetry surfaces; it includes the car/driver overview by default unless `includeOverview: false` is passed or `ui.telemetryIncludesOverview` is `false`.
433
667
  - `mountTelemetryCore(root)`: renders selected-car scalar telemetry only.
434
668
  - `mountTelemetrySectors(root)`: renders the live sector progress graph only.
435
669
  - `mountTelemetryLapTimes(root)`: renders current, last, and best lap timing only.
436
670
  - `mountTelemetrySectorTimes(root)`: renders last and best sector timing only.
437
- - `mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen })`: renders a template that combines race canvas, embedded timing tower, lower-third banner, and a right-side telemetry drawer. The drawer uses the detached telemetry components and takes layout space from the race window when opened.
671
+ - `mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen, raceDataTelemetryDetail })`: renders a template that combines race canvas, embedded timing tower, the project/radio lower-third, safety-car control, and a right-side telemetry drawer. Pass `raceDataTelemetryDetail: true` when this template should put compact S1/S2/S3 detail in the project lower-third instead of mounting a second sector popup. The drawer uses the detached telemetry components and takes layout space from the race window when opened.
438
672
  - `mountCarDriverOverview(root)`: renders the package-owned car/driver overview as a separate component with a Car/Driver toggle, center visual, and linked stat cells from the existing driver/vehicle rating components.
439
673
  - `mountRaceDataPanel(root)`: renders the project/race-data lower-third as a separate component for hosts that intentionally want it outside the race canvas.
440
674
  - `start()`: initializes PixiJS, binds mounted controls, and starts the simulation loop.
@@ -151,6 +151,7 @@ Team metadata is copied onto the same normalized driver object. `team.color` def
151
151
  simUnitsToMeters(simUnits);
152
152
  metersToSimUnits(meters);
153
153
  simSpeedToKph(simUnitsPerSecond);
154
+ simSpeedToMetersPerSecond(simUnitsPerSecond);
154
155
  kphToSimSpeed(kph);
155
156
  ```
156
157
 
package/docs/index.md CHANGED
@@ -8,6 +8,7 @@ Use these docs as the package source of truth:
8
8
  - [Rules](rules.md): race-control behavior, DRS, safety car, starts, ordering, contact handling, and simulation rules.
9
9
  - [Concepts](concepts.md): vocabulary used by the simulator.
10
10
  - [Data Contract](data_contract.md): shape of host-provided drivers, car pairings, ratings, assets, and callbacks.
11
+ - [Bring Your Own Model](training.md): environment contract, policy convention, and visual playback loop.
11
12
  - [Architecture](architecture.md): module ownership and data/control flow.
12
13
  - [Data Model Notes](data_model.md): lower-level simulator data model notes kept with package docs.
13
14
  - [Learnings](learnings.md): implementation lessons and layout verification rules learned from simulator integration issues.
@@ -23,6 +23,18 @@ import {
23
23
  } from '@inventure71/paddockjs';
24
24
  ```
25
25
 
26
+ Headless expert environment import:
27
+
28
+ ```js
29
+ import { createPaddockEnvironment, createProgressReward } from '@inventure71/paddockjs/environment';
30
+ ```
31
+
32
+ The environment subpath is the only public headless training import. It must stay free of DOM, PixiJS, CSS, and browser app dependencies.
33
+ `createProgressReward()` is published from the same subpath as a starter callback for JavaScript training loops. It must remain optional and replaceable; environment stepping must continue to work with a custom `reward(context)` callback or no reward callback.
34
+ Ray observations expose track-transition distance and car distance per ray. Track transitions use `kind: 'exit'` when the ray starts on track and reaches the border, `kind: 'entry'` when the ray starts off track and reaches the road again, and `kind: null` with max distance when no transition is visible.
35
+ The package must not own model training, model persistence, model registries, or trained policy behavior. The supported contract is: external code reads observations, returns normalized actions, and advances either the headless environment or browser expert mode.
36
+ The shared expert runtime exposes `getActionSpec()` and `getObservationSpec()` so external code can inspect controlled drivers, action ranges, sensor layout, nearby-car limits, and vector schema before connecting a model.
37
+
26
38
  All-in-one mount call:
27
39
 
28
40
  ```js
@@ -50,6 +62,7 @@ const simulator = await mountF1Simulator(root, {
50
62
  onRaceEvent,
51
63
  onLapChange,
52
64
  onRaceFinish,
65
+ expert,
53
66
  });
54
67
  ```
55
68
 
@@ -73,7 +86,6 @@ simulator.mountTimingTower(timingRoot);
73
86
  simulator.mountRaceCanvas(canvasRoot, {
74
87
  includeRaceDataPanel: true,
75
88
  includeTimingTower: true,
76
- includeTelemetrySectorBanner: true,
77
89
  timingTowerVerticalFit: 'scroll',
78
90
  });
79
91
  simulator.mountTelemetryPanel(telemetryRoot);
@@ -84,6 +96,7 @@ simulator.mountTelemetryLapTimes(lapTimesRoot);
84
96
  simulator.mountTelemetrySectorTimes(sectorTimesRoot);
85
97
  simulator.mountRaceTelemetryDrawer(raceWorkbenchRoot, {
86
98
  timingTowerVerticalFit: 'expand-race-view',
99
+ raceDataTelemetryDetail: true,
87
100
  });
88
101
 
89
102
  await simulator.start();
@@ -93,11 +106,12 @@ Standalone helper functions are also exported for host code that prefers functio
93
106
 
94
107
  ```js
95
108
  mountRaceControls(root, simulator);
109
+ mountCameraControls(root, simulator);
110
+ mountSafetyCarControl(root, simulator);
96
111
  mountTimingTower(root, simulator);
97
112
  mountRaceCanvas(root, simulator, {
98
113
  includeRaceDataPanel: true,
99
114
  includeTimingTower: true,
100
- includeTelemetrySectorBanner: true,
101
115
  timingTowerVerticalFit: 'scroll',
102
116
  });
103
117
  mountTelemetryPanel(root, simulator);
@@ -106,7 +120,10 @@ mountTelemetrySectors(root, simulator);
106
120
  mountTelemetrySectorBanner(root, simulator);
107
121
  mountTelemetryLapTimes(root, simulator);
108
122
  mountTelemetrySectorTimes(root, simulator);
109
- mountRaceTelemetryDrawer(root, simulator);
123
+ mountRaceTelemetryDrawer(root, simulator, {
124
+ raceDataTelemetryDetail: true,
125
+ });
126
+ mountCarDriverOverview(root, simulator);
110
127
  mountRaceDataPanel(root, simulator);
111
128
  ```
112
129
 
@@ -126,7 +143,7 @@ Returned controller:
126
143
  mountTelemetrySectorBanner(root),
127
144
  mountTelemetryLapTimes(root),
128
145
  mountTelemetrySectorTimes(root),
129
- mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen }),
146
+ mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen, raceDataTelemetryDetail }),
130
147
  mountRaceDataPanel(root),
131
148
  start(),
132
149
 
@@ -139,6 +156,7 @@ Returned controller:
139
156
  clearSafetyCar(),
140
157
  toggleSafetyCar(),
141
158
  getSnapshot(),
159
+ expert,
142
160
  }
143
161
  ```
144
162
 
@@ -158,26 +176,39 @@ Returned controller:
158
176
  - Mounted package surfaces show a package-owned red start-light loading overlay until `start()` finishes PixiJS, asset, control, and initial readout initialization.
159
177
  - `preset` is a preset-first API. Presets are resolved before explicit host overrides so hosts can use `dashboard`, `timing-overlay`, `compact-race`, or `full-dashboard` as a starting point and still override specific `ui` or `theme` fields.
160
178
  - `theme` is the public sizing/color contract. It maps to package CSS variables for `accentColor`, `greenColor`, `yellowColor`, `timingTowerMaxWidth`, and `raceViewMinHeight`.
179
+ - `initialCameraMode` accepts `'overview'`, `'leader'`, `'selected'`, or `'show-all'`; invalid values fall back to `'leader'`.
161
180
  - Camera controls can be embedded in the race canvas, externally mounted, or omitted by `ui.cameraControls`.
162
- - Telemetry surfaces are detached package components: core scalar readouts, sector graph, broadcast sector banner, lap-time table, and sector-time table. The broadcast sector banner shows the selected car identity, uses the selected car color for its frame/label, and keeps sector performance colors inside the sector bars. `mountTelemetryPanel()` is only a stack template around those detached pieces, and `ui.telemetryModules` controls which pieces appear in stack/drawer templates.
163
- - `mountRaceTelemetryDrawer()` creates a package-owned race workbench: race canvas, embedded timing tower, safety-car control, lower-third banner, and a right-side telemetry drawer. The drawer opens smoothly, takes width from the race view instead of overlaying it, and is inert/hidden to interaction when closed.
181
+ - Telemetry surfaces are detached package components: core scalar readouts, sector graph, broadcast sector banner, lap-time table, and sector-time table. The broadcast sector banner shows the selected car identity, uses the selected car color for its frame/label, and keeps sector performance colors inside the sector bars. It is an explicitly mounted independent surface, not the default telemetry-drawer lower-third. `mountTelemetryPanel()` is only a stack template around those detached pieces, and `ui.telemetryModules` controls which pieces appear in stack/drawer templates.
182
+ - `mountRaceTelemetryDrawer()` creates a package-owned race workbench: race canvas, embedded timing tower, lower-third banner, safety-car control, and a right-side telemetry drawer. Pass `{ raceDataTelemetryDetail: true }` when the drawer lower-third should include compact project telemetry detail. The drawer opens smoothly, takes width from the race view instead of overlaying it, and is inert/hidden to interaction when closed.
164
183
  - The FPS readout can be shown or hidden with `ui.showFps`.
165
184
  - `ui.layoutPreset: 'left-tower-overlay'` is a package-owned preset that creates a left broadcast gutter inside the race view, places the timing tower there at the same width as the default timing-board column, frames the PixiJS camera around the remaining usable race area, and keeps camera controls and start lights out of the tower area. In the combined shell, project and radio lower-thirds stay inside the race window while being allowed to cover the timing sidebar.
166
185
  - `ui.raceDataBanners.initial` controls the starting lower-third (`'project'`, `'radio'`, or `'hidden'`), and `ui.raceDataBanners.enabled` controls which lower-third types can appear.
167
186
  - `ui.raceDataBannerSize` controls lower-third sizing: `'custom'` keeps the default CSS-variable-driven banner size for host tuning, while `'auto'` uses the race space to the right of the timing board when wide enough and overlaps the timing board only when space is constrained.
187
+ - `ui.raceDataTelemetryDetail` adds compact S1/S2/S3 sector detail to the project lower-third while keeping radio mode unchanged.
168
188
  - `ui.timingTowerVerticalFit` controls vertical tower behavior in the combined overlay preset: `'expand-race-view'` grows the race window to fit the tower, while `'scroll'` crops the tower area and scrolls timing rows inside it. The same values are accepted as `mountRaceCanvas()` options when `includeTimingTower` embeds the tower in the race canvas.
169
189
  - Hosts may scale the whole mounted simulator through the container. The horizontal proportions inside package-owned presets are not public API and should not be configurable through raw width or ratio options. The camera reads the current canvas dimensions so wider or taller host windows reveal more of the race view without needing host-owned camera math. The timing tower has a package-owned max width because overly wide timing boards degrade readability; standalone hosts can constrain vertical height through the mount container and let the timing entries scroll internally. Mobile and narrow embeds are handled by package CSS: timing boards stack full-width when they no longer work as side gutters, camera controls are repositioned into the remaining race area, and full-width timing boards do not reserve horizontal camera gutter space.
170
190
  - The host does not need to provide simulator assets.
171
191
  - The host passes data, not internal DOM.
192
+ - Host driver IDs and entry `driverId` values must be unique. Entries may omit `driverNumber`; provided numbers must be unique.
193
+ - `totalLaps` is normalized to a finite positive integer before simulation so invalid input cannot produce zero-lap, negative-lap, or non-finite snapshots.
194
+ - `restart(nextOptions)` can change race data and deterministic seeds such as `trackSeed`, but it does not support changing asset URLs. Asset changes require `destroy()` and a fresh mount because PixiJS texture loading is an initialization boundary.
172
195
  - `onDriverOpen(driver)` is the navigation boundary.
173
196
  - Lifecycle callbacks are optional: `onLoadingChange`, `onReady`, `onError`, `onDriverSelect`, `onRaceEvent`, `onLapChange`, and `onRaceFinish`. Host callback failures are routed to `onError` when possible and must not stop the simulator loop.
174
197
  - Race completion is part of the simulation snapshot. Cars receive individual `finished`, `finishTime`, and `classifiedRank` values as they cross the finish distance. The first finisher sets `raceControl.winner`; final `raceControl.classification` and `raceControl.finished` are set only after every car finishes. At that point race control switches to `safety-car`, the final order freezes, and the field keeps circulating under safety-car behavior.
175
- - The simulator must stay interactive after being installed through `npm install ../PaddockJS`.
198
+ - The simulator must stay interactive after being installed through `npm install @inventure71/paddockjs`.
176
199
  - The package must build correctly through a browser bundler that supports JavaScript modules, CSS imports, and image imports.
177
200
  - The simulation should remain deterministic for the same seed, track seed, drivers, entries, and rules.
178
201
  - When `trackSeed` is omitted in a browser mount, the simulator creates a fresh procedural circuit for that mount. Explicit `trackSeed` values are deterministic and cached by seed for repeated mounts.
179
202
  - The renderer should target a paced 60 FPS simulation/render loop.
180
203
  - The render loop should pause while the race canvas is offscreen or the document is hidden, then resume without catching up the elapsed hidden time. Layout measurements needed for overlay camera safe areas should be cached between resize/layout invalidations. Runtime DOM updates should skip unchanged text/markup so visible embeds do not rewrite stable readouts every frame.
204
+ - Restart and rerender paths must destroy replaced PixiJS display children while preserving shared loaded textures.
205
+ - Restart supports race/data/seed changes but does not support asset URL changes or browser expert mode changes. Texture loading and ticker ownership are mount-time boundaries; hosts must destroy and mount again to change either boundary.
206
+ - Expert environment code can create a headless `createPaddockEnvironment()` from the `@inventure71/paddockjs/environment` subpath. It requires explicit `controlledDrivers`, accepts normalized actions `{ steering, throttle, brake }`, advances only through `step(actions)`, and returns environment-loop JavaScript results with `observation`, `reward`, `terminated`, `truncated`, `done`, `events`, `state`, and `info`.
207
+ - Expert ray sensors originate from the controlled car center. The default compact set is `[-135, -60, -20, 0, 20, 60, 135, 180]`, giving forward, side, and rear awareness while staying small. Rays detect track edges against the actual track geometry and detect car hits by ray-to-car-footprint intersection.
208
+ - Browser expert mode is opt-in with `expert: { enabled: true, controlledDrivers, frameSkip }`. When enabled, the returned controller exposes `expert.reset()`, `expert.step(actions)`, `expert.getObservation()`, and `expert.getState()`.
209
+ - Browser expert mode wraps the same `RaceSimulation` instance that the visual canvas renders. It must not create a parallel simulation for the same mount.
210
+ - Browser expert mode disables automatic ticker-driven simulation advancement. The visual canvas updates only after explicit expert `reset()` or `step(actions)` calls.
211
+ - Browser expert mode may opt into `expert.visualizeSensors: true` or `expert.visualizeSensors: { rays: true }`. When enabled, ray sensors render in the race canvas world layer from the controlled cars, using the same observation result produced by explicit expert steps.
181
212
 
182
213
  ## Current Visible Features
183
214
 
@@ -206,7 +237,8 @@ Returned controller:
206
237
  - A host build tool that handles CSS and image imports. Vite is the currently verified bundler.
207
238
  - `pixi.js` available through the package dependency graph.
208
239
 
209
- Raw Node imports are not a supported runtime check because the package imports CSS and image assets. Verify through a browser bundler and host page instead.
240
+ Raw Node imports of the package root are not a supported runtime check because the browser component entry imports CSS and image assets. The `@inventure71/paddockjs/environment` subpath is the supported browser-free import path for headless JavaScript training.
241
+ The repository starter loop is executable with `node examples/train-basic-policy.mjs`. It is a dependency-free example that trains/evaluates a tiny policy against the environment contract; it is not a packaged Gymnasium bridge or a recommended final RL algorithm.
210
242
 
211
243
  ## Verification
212
244
 
@@ -221,15 +253,14 @@ Expected:
221
253
  - All Vitest tests pass.
222
254
  - `npm pack --dry-run` succeeds and includes source files plus bundled assets.
223
255
 
224
- Run from the portfolio host:
256
+ Run from a browser host that consumes the published package:
225
257
 
226
258
  ```bash
227
- npm install ../PaddockJS
259
+ npm install @inventure71/paddockjs@latest
228
260
  npm run check
229
261
  ```
230
262
 
231
263
  Expected:
232
264
 
233
- - PaddockJS tests pass through the host script.
234
- - The host F1 bundle builds.
265
+ - The host bundle builds with PaddockJS resolved from npm.
235
266
  - Browser smoke shows shell, canvas, driver rows, FPS readout, and working `Open project` navigation.
@@ -0,0 +1,104 @@
1
+ # Bring Your Own Model
2
+
3
+ PaddockJS does not train models for you. It provides a simulator environment contract so users can train with any toolchain, then run the resulting policy in the visual simulator.
4
+
5
+ ## Boundary
6
+
7
+ PaddockJS owns:
8
+
9
+ - deterministic simulator stepping
10
+ - observations
11
+ - normalized actions
12
+ - events
13
+ - optional reward callback hooks
14
+ - browser expert stepping
15
+ - sensor visualization
16
+
17
+ Users own:
18
+
19
+ - ML framework choice
20
+ - training algorithm
21
+ - model weights
22
+ - model storage
23
+ - model loading
24
+ - reward design beyond optional starter helpers
25
+
26
+ ## Policy Shape
27
+
28
+ Use this convention for browser playback:
29
+
30
+ ```js
31
+ const policy = {
32
+ predict(observation) {
33
+ return {
34
+ steering: 0,
35
+ throttle: 1,
36
+ brake: 0,
37
+ };
38
+ },
39
+ };
40
+ ```
41
+
42
+ `predict()` receives one controlled driver's observation. It returns normalized controls:
43
+
44
+ - `steering`: `-1` full left, `1` full right
45
+ - `throttle`: `0` to `1`
46
+ - `brake`: `0` to `1`
47
+
48
+ ## Headless Training Loop
49
+
50
+ ```js
51
+ import { createPaddockEnvironment } from '@inventure71/paddockjs/environment';
52
+
53
+ const env = createPaddockEnvironment({
54
+ drivers,
55
+ entries,
56
+ controlledDrivers: ['budget'],
57
+ frameSkip: 4,
58
+ reward: myReward,
59
+ });
60
+
61
+ let result = env.reset();
62
+ while (!result.done) {
63
+ const observation = result.observation.budget;
64
+ const action = policy.predict(observation);
65
+ result = env.step({ budget: action });
66
+ }
67
+ ```
68
+
69
+ ## Visual Playback Loop
70
+
71
+ ```js
72
+ import { mountF1Simulator } from '@inventure71/paddockjs';
73
+
74
+ const simulator = await mountF1Simulator(root, {
75
+ drivers,
76
+ entries,
77
+ expert: {
78
+ enabled: true,
79
+ controlledDrivers: ['budget'],
80
+ frameSkip: 4,
81
+ visualizeSensors: { rays: true },
82
+ },
83
+ });
84
+
85
+ function frame() {
86
+ const observation = simulator.expert.getObservation().budget;
87
+ const action = policy.predict(observation);
88
+ simulator.expert.step({ budget: action });
89
+ requestAnimationFrame(frame);
90
+ }
91
+
92
+ frame();
93
+ ```
94
+
95
+ ## Specs
96
+
97
+ Use specs to connect external training code without guessing:
98
+
99
+ ```js
100
+ const actionSpec = env.getActionSpec();
101
+ const observationSpec = env.getObservationSpec();
102
+ ```
103
+
104
+ These specs describe the action ranges, controlled drivers, ray layout, nearby-car limits, and vector schema.