@inventure71/paddockjs 1.0.0 → 2.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.
Files changed (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
package/docs/training.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
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
4
 
5
+ If you already have a trained model and need to connect it to PaddockJS, see [Custom Model Controller Guide](custom_model_controller.md).
6
+
5
7
  ## Boundary
6
8
 
7
9
  PaddockJS owns:
@@ -29,69 +31,169 @@ Users own:
29
31
  The current expert API is a JavaScript environment contract. It supports:
30
32
 
31
33
  - `createPaddockEnvironment()` from `@inventure71/paddockjs/environment`
34
+ - `createPaddockDriverControllerLoop()` for shared browser/headless controller orchestration
32
35
  - explicit `controlledDrivers`
33
36
  - normalized actions: `steering`, `throttle`, `brake`, optional `pitIntent`, and optional `pitCompound`
34
37
  - manual stepping with optional `frameSkip`
35
- - object observations in real units plus a versioned numeric vector and schema, including rays, nearby cars, track lookahead/curvature, pit-lane surface, pit target compound, pit service state, pit-lane open state, and red-flag state
38
+ - object observations in real units plus a versioned numeric vector and schema, including rays, nearby cars, track lookahead/curvature, local physical driver senses, pit-lane surface, pit target compound, pit service state, pit-lane open state, and red-flag state
39
+ - `observation.object.self.appliedControls`, which reports the normalized steering, throttle, and brake controls that actually drove the latest physics step
40
+ - compact observation output modes for vector-only or object-only training loops
36
41
  - full simulator state under `result.state.snapshot`
37
42
  - global and per-controlled-driver events
38
43
  - optional host-owned `reward(context)` callbacks, or no reward
39
44
  - `getActionSpec()` and `getObservationSpec()`
40
45
  - scenarios with `participants: 'all'`, `'controlled-only'`, or an explicit driver-id list
41
- - reset-only scenario placement through `scenario.preset`, `scenario.placements`, and `scenario.traffic`
46
+ - reset-only scenario placement through `scenario.preset`, `scenario.placements`, `scenario.traffic`, and episode-boundary `resetDrivers(placements)`
47
+ - multi-car interaction profiles through `participantInteractions`, including no-collision training cars that are hidden from other cars' ray and nearby-car sensors by default
48
+ - per-driver episode state and neutral step metrics for batched training loops
42
49
  - neutral rollout recording through `createRolloutRecorder()`
43
50
  - deterministic evaluation metrics through `runEnvironmentEvaluation()` and `createEvaluationTracker()`
44
51
  - a JSON-serializable worker protocol wrapper through `createEnvironmentWorkerProtocol()`
52
+ - optional frame observers through `externalRenderer` for transport-owned visual streaming (`{ snapshot, observation, meta }`)
45
53
  - built-in AI for non-controlled cars
46
54
  - browser expert mode through `mountF1Simulator(..., { expert })`
47
55
  - opt-in browser ray visualization with `expert.visualizeSensors`
48
56
 
49
57
  The environment also accepts `rules` as a narrow override of the existing race rules, such as `standingStart: false` for training loops. Rule overrides change simulator behavior; keep them fixed when comparing policy runs.
50
58
 
59
+ `reward(context)` is a user-owned formula over package-owned facts. The context includes the same neutral `metrics` and per-driver `episode` state returned by each step result, so training code does not need to re-derive legality, destruction, or episode termination from raw snapshots:
60
+
61
+ ```js
62
+ const env = createPaddockEnvironment({
63
+ drivers,
64
+ entries,
65
+ controlledDrivers: ['budget'],
66
+ reward({ metrics, episode }) {
67
+ if (metrics.destroyed) return -200;
68
+ if (metrics.offTrack) return -12;
69
+ if (episode.terminated) return 0;
70
+ return metrics.legalProgressDeltaMeters;
71
+ },
72
+ });
73
+ ```
74
+
75
+ PaddockJS does not define an official reward. It exposes neutral simulator facts; users choose the objective.
76
+
51
77
  ## Deferred
52
78
 
53
79
  These are not part of the current package API:
54
80
 
55
81
  - Python Gymnasium wrapper
56
- - static obstacle/ghost-car scenario modes
82
+ - static obstacle scenario modes
57
83
  - debug mutation APIs for arbitrary simulator state
58
84
  - assisted-control modes that blend model output with built-in AI
59
85
  - model loading, model storage, model registries, or trained policies
60
86
 
61
87
  Those features need separate design work because they change scenario ownership, reproducibility, and the safety boundary between user experiments and package internals. The worker protocol is intentionally only a message wrapper around the JavaScript environment; it is not a Gymnasium package and does not choose a Python RL stack.
62
88
 
63
- ## Policy Shape
89
+ ## Controller Shape
64
90
 
65
- Use this convention for browser playback:
91
+ Use a user-owned controller for browser playback and JavaScript training-style loops:
66
92
 
67
93
  ```js
68
- const policy = {
69
- predict(observation) {
70
- return {
71
- steering: 0,
72
- throttle: 1,
73
- brake: 0,
74
- };
94
+ import {
95
+ createPaddockDriverControllerLoop,
96
+ } from '@inventure71/paddockjs';
97
+
98
+ const controller = {
99
+ async init(ctx) {
100
+ this.model = await loadUserModel(ctx.observationSpec);
101
+ },
102
+ async reset(ctx) {
103
+ this.hidden = resetHidden(ctx.controlledDrivers.length);
104
+ },
105
+ async decideBatch(ctx) {
106
+ const vectors = ctx.orderedObservations.map((item) => item.vector);
107
+ const outputs = await this.model.predictBatch(vectors, this.hidden);
108
+ return Object.fromEntries(ctx.controlledDrivers.map((driverId, index) => [
109
+ driverId,
110
+ {
111
+ steering: outputs[index].steering,
112
+ throttle: Math.max(outputs[index].accel, 0),
113
+ brake: Math.max(-outputs[index].accel, 0),
114
+ },
115
+ ]));
116
+ },
117
+ onStep(ctx) {
118
+ recordUserOwnedRewardOrStats(ctx.metrics, ctx.actions);
75
119
  },
76
120
  };
121
+
122
+ const loop = createPaddockDriverControllerLoop({
123
+ runtime: env,
124
+ controller,
125
+ actionRepeat: 4,
126
+ });
77
127
  ```
78
128
 
79
- `predict()` receives one controlled driver's observation. It returns normalized controls:
129
+ `decideBatch()` receives all controlled drivers in a stable order and returns one normalized action map:
80
130
 
81
- - `steering`: `-1` full left, `1` full right
131
+ - `steering`: absolute steering target, where `-1` is maximum left, `0` is centered, `1` is maximum right, and values between are percentages of the maximum steering angle
82
132
  - `throttle`: `0` to `1`
83
133
  - `brake`: `0` to `1`
84
134
  - `pitIntent`: optional `0`, `1`, or `2`; `0` means no pit request and is accepted as a no-op even when pit stops are disabled, `1` means keep trying until a free-enough pit-entry window appears, and `2` means commit to entering at the next pit-entry window even if pit-lane capacity or gap checks would block mode `1`
85
135
  - `pitCompound`: optional tire target such as `'S'`, `'M'`, or `'H'`; it must be one of `rules.modules.tireStrategy.compounds`
86
136
 
87
- Controlled drivers do not receive tire-threshold automatic pit calls from the built-in strategy. A model must request a stop with `pitIntent`, and may choose the tire with `pitCompound`; then the simulator owns the pit entry, queue, service, penalty hold, tire change, and pit exit sequence until `pitStopStatus` returns to `completed`. For deterministic training, keep `rules.modules.pitStops.variability.enabled` false or set `rules.modules.pitStops.variability.perfect: true`; when variability is enabled without `perfect`, team `pitCrew` speed, consistency, and reliability affect service time from the same seeded simulation RNG.
137
+ `steering`, `throttle`, and `brake` are required for every controlled-driver action. Missing or non-finite vehicle-control fields fail validation instead of silently defaulting to zero, so broken model output does not become a hidden coast or brake command.
138
+
139
+ The controller owns model loading, hidden state, reward/stat logging, and any checkpoint format. PaddockJS owns only cached action/observation specs, compact observation delivery, action validation, batching order, optional action repeat, and normal physics stepping. A one-car `policy.predict(observation)` function can still be wrapped inside `decideBatch()`, but package examples should prefer the batched controller shape.
140
+
141
+ In `onStep(ctx)`, `ctx.actions` is the action map used for the current physics step and `ctx.previousActions` is the last action map actually applied before it. The first frame after reset has no previous per-driver action. Scheduled `loop.start()` playback may begin from a fresh loop without an explicit `reset()`, and controller/runtime errors stop playback while leaving the thrown value in `loop.stats.lastError`.
142
+
143
+ Controlled drivers do not receive tire-threshold automatic pit calls from the built-in strategy. A model must request a stop with `pitIntent`, and may choose the tire with `pitCompound`; then the simulator owns the pit entry, queue, service, penalty hold, tire change, and pit exit sequence until `pitStopStatus` returns to `completed`. For deterministic training, keep `rules.modules.pitStops.variability.enabled` false or set `rules.modules.pitStops.variability.perfect: true`; when variability is enabled without `perfect`, team `pitCrew` speed, consistency, and reliability affect service time from the same seeded simulation RNG. For single-car driving skill work where tyre management is out of scope, set `rules.modules.tireDegradation.enabled: false` so `tireEnergy` stays fixed and the model does not learn a hidden degradation schedule.
88
144
 
89
145
  `observation.object.self.onTrack` follows the simulator's wheel-level legality rules: track, kerb, and legal pit-lane/box surfaces are on-track for reward/observation purposes, while gravel, grass, and barrier surfaces are off-track. Invalid `observation.lookaheadMeters` values fall back to `[20, 50, 100, 150]` so fixed observation schemas stay usable.
90
146
 
147
+ Use `observation.profile: 'physical-driver'` when training a policy that should rely on local driver-like senses instead of privileged future track data. This profile keeps the normal action contract, exposes yaw rate, local left/right boundary distance, four contact-patch surface readings, richer opponent radar fields, and surface-aware ray channels in the vector schema. Unless `lookaheadMeters` is explicitly provided, the physical-driver profile uses no track lookahead samples.
148
+
149
+ `observation.object.self.appliedControls` is diagnostic/reference data for the
150
+ latest physics step. For controlled cars it mirrors the normalized action after
151
+ environment validation. In `actionPolicy: 'report'` mode, stepping with a
152
+ missing or invalid vehicle action releases stale manual controls, lets the
153
+ built-in driver own that step, and exposes the exact normalized controls the
154
+ built-in driver applied. This is intended for audits or clean local imitation
155
+ datasets; it does not blend built-in AI with a model during normal controlled
156
+ stepping.
157
+
158
+ For model training, prefer `physicsMode: 'simulator'` so the policy learns against the same grip, yaw-rate, contact-patch, kerb, and runoff behavior exposed by those senses. Arcade physics can still be useful as a debugging baseline, but it should not be treated as the main training target for realistic driver policies.
159
+
160
+ Surface-aware rays are opt-in because they are more expensive than the default road-edge/car channels. The environment normalizes both the legacy compact ray shape and the newer per-ray layout:
161
+
162
+ ```js
163
+ const env = createPaddockEnvironment({
164
+ drivers,
165
+ controlledDrivers: ['budget'],
166
+ physicsMode: 'simulator',
167
+ observation: { profile: 'physical-driver' },
168
+ sensors: {
169
+ rays: {
170
+ layout: 'driver-front-heavy',
171
+ channels: ['roadEdge', 'kerb', 'illegalSurface', 'car'],
172
+ precision: 'driver',
173
+ rays: [
174
+ { id: 'front', angleDegrees: 0, lengthMeters: 260 },
175
+ { id: 'right', angleDegrees: 90, lengthMeters: 80 },
176
+ ],
177
+ },
178
+ },
179
+ });
180
+ ```
181
+
182
+ Only requested ray channels are computed. The default compact config still computes the existing road-edge and car readings, so existing policy vectors keep their current shape unless the profile or sensor config opts into richer fields. Barrier walls are not a ray channel; they are visible track geometry and a terminal destruction condition in both physics modes. Active ray objects, vectors, schemas, and visualizations must not expose barrier ray fields.
183
+
184
+ `precision: 'driver'` is the default and the recommended model-facing ray contract. It returns the normal sampled driver-sensor distances without extra refinement. `precision: 'debug'` exists only for clearly labeled diagnostics with additional edge refinement. If the Policy Runner or expert visualization is showing model senses, it must render the active observation values exactly and must not replace them with debug-precision readings.
185
+
186
+ Track-position, wheel-surface, pit-lane, and ray fallback queries can be backed by an internal startup-built track query index. Browser/expert mounts, headless environments, and direct race-simulation construction now default to indexed queries and can opt out with `trackQueryIndex: false` for comparison runs. Compact vector-only, schema-free, no-state environment runs enable that index on the environment-owned simulation, and `batch-training` ray runs keep it enabled even when full state output is returned for inspection. Internal benchmarks can explicitly compare indexed and legacy-disabled modes. This does not change the observation contract; it makes repeated `nearestTrackState()` style queries use small spatial candidate sets instead of scanning the whole track when the indexed path is active. Rays also use indexed boundary intersections for nearby off-track recovery cases, so the same driver-facing sensor values stay fast without switching to a debug-only precision contract.
187
+
188
+ `resetDrivers()` is part of episode control, not policy action. After reset placement, the environment immediately reclassifies the selected cars against the same runoff/barrier rules used during stepping before building observations. Recovery starts inside the legal recovery band remain normal physical starts. Placements that are already inside the barrier destruction boundary return a terminal per-driver episode state and miss-valued rays, so a batch loop can assign its own negative reward and reset that driver without paying for far-out ray geometry.
189
+
91
190
  ## Headless Training Loop
92
191
 
93
192
  ```js
94
- import { createPaddockEnvironment } from '@inventure71/paddockjs/environment';
193
+ import {
194
+ createPaddockDriverControllerLoop,
195
+ createPaddockEnvironment,
196
+ } from '@inventure71/paddockjs/environment';
95
197
 
96
198
  const env = createPaddockEnvironment({
97
199
  drivers,
@@ -104,14 +206,112 @@ const env = createPaddockEnvironment({
104
206
  reward: myReward,
105
207
  });
106
208
 
209
+ const loop = createPaddockDriverControllerLoop({
210
+ runtime: env,
211
+ controller,
212
+ actionRepeat: 4,
213
+ });
214
+
215
+ let result = await loop.reset();
216
+ while (!result.done) result = await loop.step();
217
+ ```
218
+
219
+ ## Multi-Car No-Collision Training
220
+
221
+ Use `participantInteractions` when you want several physics-driven cars in the same environment without having them collide or contaminate each other's sensors. For batched same-environment learning, use `batch-training`.
222
+
223
+ ```js
224
+ import { createPaddockEnvironment } from '@inventure71/paddockjs/environment';
225
+
226
+ const env = createPaddockEnvironment({
227
+ drivers,
228
+ entries,
229
+ controlledDrivers: ['model-a', 'model-b'],
230
+ frameSkip: 4,
231
+ scenario: {
232
+ participants: ['model-a', 'model-b'],
233
+ },
234
+ participantInteractions: {
235
+ defaultProfile: 'batch-training',
236
+ },
237
+ observation: {
238
+ profile: 'physical-driver',
239
+ output: 'vector',
240
+ includeSchema: false,
241
+ },
242
+ });
243
+
107
244
  let result = env.reset();
108
- while (!result.done) {
109
- const observation = result.observation.budget;
110
- const action = policy.predict(observation);
111
- result = env.step({ budget: action });
245
+ result = env.step({
246
+ 'model-a': policyA.predict(result.observation['model-a']),
247
+ 'model-b': policyB.predict(result.observation['model-b']),
248
+ });
249
+ ```
250
+
251
+ `batch-training` is still a real car mode, not a replay ghost and not a teleport path:
252
+
253
+ - the car remains in `result.state.snapshot.cars`
254
+ - the car still moves only through steering, throttle, brake, and optional pit intent
255
+ - the car still has tire, pit, timing, lap, and rules state
256
+ - the car is excluded from race order/classification by default
257
+ - the car does not collide with other cars
258
+ - the car does not block pit-lane service occupancy
259
+ - the car is hidden from other cars' ray sensors by default
260
+ - the car is hidden from other cars' `nearbyCars` observations by default
261
+
262
+ In the browser renderer, no-collision participants still look like solid cars, but get a blue non-collision outline marker. Replay ghosts remain visually separate: translucent trajectory overlays from `snapshot.replayGhosts`, not physics cars from `snapshot.cars`.
263
+
264
+ Sensor hiding is per target car. If `model-b` is `batch-training`, then `model-a`'s rays and `nearbyCars` ignore `model-b`. If every training car should be invisible to every other training car, set `participantInteractions.defaultProfile: 'batch-training'`.
265
+
266
+ Replay ghosts are also sensor-hidden by default. If a replay reference should be visible to policy sensors for comparison experiments, opt it in through its own `sensors.detectableByRays` or `sensors.detectableAsNearby` flags. Sensor results mark those targets as replay ghosts; they still do not collide, rank, pit, or receive controls.
267
+
268
+ If you need a non-colliding car that still appears in sensors, use `phantom-race` instead. If you need a one-off exception, override the flag explicitly:
269
+
270
+ ```js
271
+ participantInteractions: {
272
+ drivers: {
273
+ 'model-b': {
274
+ profile: 'batch-training',
275
+ detectableByRays: true,
276
+ detectableAsNearby: true,
277
+ },
278
+ },
112
279
  }
113
280
  ```
114
281
 
282
+ Use that override deliberately. The default no-collision training profile is sensor-hidden because parallel training rollouts usually should not alter each other's observation tensors.
283
+
284
+ Compact vector mode is intended for high-throughput loops. `env.getObservationSpec()` remains the canonical schema source, so external code can request `output: 'vector'` and `includeSchema: false` without serializing object observations and schema data on every step. JavaScript training loops can also request `vectorType: 'float32'` for typed numeric buffers. Keep the default array output for JSON-only bridges unless the bridge explicitly packs typed arrays.
285
+
286
+ Use `result.stateOutput` to avoid returning more state than the loop needs:
287
+
288
+ ```js
289
+ const env = createPaddockEnvironment({
290
+ drivers,
291
+ entries,
292
+ controlledDrivers: agentIds,
293
+ observation: { profile: 'physical-driver', output: 'vector', includeSchema: false },
294
+ result: {
295
+ stateOutput: 'none',
296
+ resetDriversObservationScope: 'reset',
297
+ },
298
+ });
299
+ ```
300
+
301
+ `stateOutput: 'minimal'` returns the lean public observation snapshot. `stateOutput: 'none'` returns `state: null`. The default is still `full` for existing callers. When `stateOutput: 'none'`, `observation.output: 'vector'`, and `includeSchema: false`, the environment uses an internal compact training snapshot before building observations and metrics. That optimization is not exposed as policy state, and reward callbacks still receive the documented reward context rather than a browser/render-only snapshot.
302
+
303
+ On the local 20-car batch-training benchmark used for this package work (`physicsMode: 'simulator'`, `frameSkip: 4`, `physical-driver`, `driver-front-heavy` rays), the measured environment action cost after compact output and indexed track queries was approximately:
304
+
305
+ ```txt
306
+ nearest track states 1.52x faster than legacy-disabled
307
+ pit lane states 2.02x faster than legacy-disabled
308
+ sampled high-curvature off-track rays 14.81x faster than legacy-disabled
309
+ wheel surface near pit connector 1.12x faster than legacy-disabled
310
+ 20-car batch recovery environment 1.03x faster than legacy-disabled
311
+ ```
312
+
313
+ These numbers are hardware- and track-dependent, but the relative result is the useful guidance: compact state/observation output reduces serialization/allocation overhead, and indexed track queries prevent off-track recovery rays from becoming the dominant cost center.
314
+
115
315
  ## Scenario Reset Control
116
316
 
117
317
  Scenario placement belongs to environment reset, not to model control. It is useful for deterministic training or evaluation starts such as cornering, recovery, overtaking packs, and pit entry:
@@ -146,6 +346,42 @@ const env = createPaddockEnvironment({
146
346
 
147
347
  Supported fixed presets are `cornering`, `off-track-recovery`, `overtaking-pack`, and `pit-entry`. Explicit `placements` override preset placement for the same driver. `traffic` places cars relative to another placed or existing car. After reset, `step(actions)` still advances controlled cars only through the normal action contract.
148
348
 
349
+ For batched training, individual controlled cars can be reset between episodes without recreating the whole simulation:
350
+
351
+ ```js
352
+ env.resetDrivers({
353
+ 'model-a': {
354
+ distanceMeters: 1200,
355
+ offsetMeters: 3,
356
+ speedKph: 80,
357
+ headingErrorRadians: 0.2,
358
+ },
359
+ }, {
360
+ observationScope: 'reset',
361
+ stateOutput: 'none',
362
+ });
363
+ ```
364
+
365
+ `resetDrivers()` is an episode-boundary API. It increments that driver's `episodeId`, resets its `episodeStep`, clears manual controls and pit intent, and places the car through the same simulator state-reset path used by scenarios. It does not rewind global `info.step`; max-step truncation is evaluated from the selected driver's own `episodeStep`, so one truncated driver can be reset while the rest of the batch keeps its current episode state. It is not available through `step(actions)` and is not a movement shortcut during an episode. Passing `{ observationScope: 'reset', stateOutput: 'none' }` keeps reset responses small for batched runners.
366
+
367
+ The worker protocol mirrors this with a `resetDrivers` message:
368
+
369
+ ```js
370
+ protocol.handle({
371
+ id: 'reset-agent-3',
372
+ type: 'resetDrivers',
373
+ placements: {
374
+ 'agent-3': { distanceMeters: 1200, offsetMeters: 0, speedKph: 80 },
375
+ },
376
+ resultOptions: {
377
+ observationScope: 'reset',
378
+ stateOutput: 'none',
379
+ },
380
+ });
381
+ ```
382
+
383
+ For state polling, worker `getState` messages accept `stateOptions: { output: 'full' | 'minimal' | 'none' }`; `resultOptions.stateOutput` is accepted as a compatibility alias on that message type.
384
+
149
385
  ## Rollouts And Evaluation
150
386
 
151
387
  PaddockJS can export neutral transition data without owning a trainer:
@@ -155,14 +391,16 @@ import { createRolloutRecorder } from '@inventure71/paddockjs/environment';
155
391
 
156
392
  const recorder = createRolloutRecorder();
157
393
  let result = env.reset();
158
- const action = { budget: policy.predict(result.observation.budget) };
394
+ const action = { budget: { steering: 0, throttle: 1, brake: 0 } };
159
395
  const next = env.step(action);
160
396
  recorder.recordStep(result, action, next);
161
397
  ```
162
398
 
163
399
  Each transition has `{ observation, action, reward, nextObservation, terminated, truncated, info }`. If the environment has no reward callback, `reward` is still `null`; the recorder does not invent one.
164
400
 
165
- Deterministic evaluation helpers report simulator quality metrics such as distance, off-track steps, contacts, recovery success, pass count, and first lap time when available. They do not update a model or compute rewards.
401
+ Environment step results also include reward-neutral `metrics[driverId]` facts such as progress delta, legal progress delta, kerb use, illegal/off-track state, severe cuts, destruction state, under-30-kph state, spin/backwards/instability state, lap completion, lap time, and contact count. `spinOrBackwards` is true for large heading-error backwards cases and for active simulator instability labels such as `spin-risk`, `understeer`, `oversteer`, and `destroyed`. `contactCount` counts each normalized physical contact event once per involved driver even when the event carries both legacy `carId`/`otherCarId` fields and normalized `driverIds`. These metrics are facts for logging, evaluation, and user-defined reward functions; PaddockJS does not turn them into a built-in objective. If a car touches the rendered barrier wall's inner face in either physics mode, that driver receives `metrics[driverId].destroyed: true` and `info.drivers[driverId].endReason: 'destroyed'`; external training code should assign any super-negative crash reward itself and then call `resetDrivers()` for the next episode. If opt-in `rules.modules.stalledDnf.enabled: true` retires a controlled car for being stuck off legal surfaces, the episode terminates with `endReason: 'stalled-off-track'` while `metrics[driverId].destroyed` stays `false`.
402
+
403
+ Deterministic evaluation helpers report simulator quality metrics such as distance, off-track steps, contacts, recovery success, pass count, and first lap time when available. They use the same wheel-level legal-surface and contact-event semantics as environment metrics. `runEnvironmentEvaluation()` can accept compact base options with `result.stateOutput: 'none'`; it internally requests the minimal snapshot needed for evaluation bookkeeping. Direct `createEvaluationTracker(result)` calls require a result with `state.snapshot`, so use `stateOutput: 'minimal'` or `full` for manual tracker wiring. Evaluation helpers do not update a model or compute rewards.
166
404
 
167
405
  ## Example Rewards
168
406
 
@@ -171,7 +409,10 @@ Deterministic evaluation helpers report simulator quality metrics such as distan
171
409
  ## Visual Playback Loop
172
410
 
173
411
  ```js
174
- import { mountF1Simulator } from '@inventure71/paddockjs';
412
+ import {
413
+ createPaddockDriverControllerLoop,
414
+ mountF1Simulator,
415
+ } from '@inventure71/paddockjs';
175
416
 
176
417
  const simulator = await mountF1Simulator(root, {
177
418
  drivers,
@@ -184,14 +425,15 @@ const simulator = await mountF1Simulator(root, {
184
425
  },
185
426
  });
186
427
 
187
- function frame() {
188
- const observation = simulator.expert.getObservation().budget;
189
- const action = policy.predict(observation);
190
- simulator.expert.step({ budget: action });
191
- requestAnimationFrame(frame);
192
- }
428
+ const loop = createPaddockDriverControllerLoop({
429
+ runtime: simulator.expert,
430
+ controller,
431
+ actionRepeat: 4,
432
+ mode: 'browser',
433
+ });
193
434
 
194
- frame();
435
+ await loop.reset();
436
+ loop.start();
195
437
  ```
196
438
 
197
439
  ## Specs
@@ -0,0 +1,62 @@
1
+ # Python Base Policy Server Example
2
+
3
+ This folder provides a neutral extension point for browser Policy Runner integrations.
4
+
5
+ It is not a trainer and not a model/checkpoint format owner. It only shows transport contracts:
6
+
7
+ - HTTP inference stubs
8
+ - `POST /policy/reset`
9
+ - `POST /policy/reset-state`
10
+ - `POST /policy/decide-batch`
11
+ - Preview WebSocket stream
12
+ - `ws://127.0.0.1:8787/preview`
13
+ - packet shape: `{ type: "preview:snapshot", snapshot, observation, meta }`
14
+ - Preview latest-frame HTTP endpoint (for polling visualizers)
15
+ - `http://127.0.0.1:8787/preview/frame`
16
+
17
+ ## Run
18
+
19
+ ```bash
20
+ python -m pip install -r examples/python/requirements.txt
21
+ python examples/python/base_policy_server.py
22
+ ```
23
+
24
+ Then in Policy Runner:
25
+
26
+ For browser-owned stepping:
27
+
28
+ 1. Select controller `Policy server`
29
+ 2. Use `http://127.0.0.1:8787`
30
+
31
+ For external render-only preview:
32
+
33
+ 1. Select controller `Live preview stream`
34
+ 2. Use either:
35
+ - `ws://127.0.0.1:8787/preview` (WebSocket push)
36
+ - `http://127.0.0.1:8787/preview/frame` (HTTP polling)
37
+ 3. Connect
38
+
39
+ The base server enables CORS for local browser previews so `policy-runner.html`
40
+ can call it from the Vite preview origin.
41
+
42
+ Direct launch URL example (when local preview runs on `5174`):
43
+
44
+ `http://127.0.0.1:5174/policy-runner.html?controller=live-node-view&liveUrl=ws://127.0.0.1:8787/preview`
45
+
46
+ ## Extend
47
+
48
+ Subclass `BasePolicyServer` and override:
49
+
50
+ - `init_policy(ctx)`
51
+ - `reset_policy(ctx)`
52
+ - `reset_policy_state(driver_ids)`
53
+ - `decide_batch(ctx)`
54
+ - `publish_preview_frame(frame)` (optional)
55
+
56
+ Use `broadcast_preview_frame(snapshot, observation, meta)` to push authoritative frames to the browser renderer.
57
+
58
+ ## Important Boundary
59
+
60
+ - This server does not step the simulator by itself.
61
+ - Your training/runtime bridge (Python orchestration + JS environment) must provide authoritative frames by calling `broadcast_preview_frame(...)` after environment `reset/step/resetDrivers`.
62
+ - In browser `Live preview stream`, the simulator is render-only while attached; local browser stepping controls are intentionally disabled.
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Neutral PaddockJS policy server base example.
3
+
4
+ This file is intentionally minimal and transport-focused:
5
+ - HTTP policy endpoints:
6
+ - POST /policy/reset
7
+ - POST /policy/reset-state
8
+ - POST /policy/decide-batch
9
+ - WebSocket preview endpoint:
10
+ - /preview (broadcasts {snapshot, observation, meta})
11
+
12
+ Extend `BasePolicyServer` and override hook methods to plug your own model/runtime.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional
21
+
22
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import JSONResponse
25
+ import uvicorn
26
+
27
+
28
+ def _zero_action() -> Dict[str, float]:
29
+ return {
30
+ "steering": 0.0,
31
+ "throttle": 0.0,
32
+ "brake": 0.0,
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class ServerState:
38
+ session_id: int = 0
39
+ controlled_drivers: List[str] = field(default_factory=list)
40
+ connections: List[WebSocket] = field(default_factory=list)
41
+ latest_preview_frame: Optional[Dict[str, Any]] = None
42
+
43
+
44
+ class BasePolicyServer:
45
+ """Base extension surface for custom policy/runtime integrations."""
46
+
47
+ def __init__(self) -> None:
48
+ self.app = FastAPI(title="PaddockJS Base Policy Server")
49
+ self.app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=["*"],
52
+ allow_methods=["*"],
53
+ allow_headers=["*"],
54
+ )
55
+ self.state = ServerState()
56
+ self._broadcast_lock = asyncio.Lock()
57
+ self._register_routes()
58
+
59
+ # ----- Extension hooks -------------------------------------------------
60
+
61
+ async def init_policy(self, context: Mapping[str, Any]) -> None:
62
+ """Initialize policy/runtime resources on full reset."""
63
+
64
+ async def reset_policy(self, context: Mapping[str, Any]) -> None:
65
+ """Reset all policy runtime state for a new run/session."""
66
+
67
+ async def reset_policy_state(self, driver_ids: Iterable[str]) -> None:
68
+ """Reset only selected driver-local model state."""
69
+
70
+ async def decide_batch(self, context: Mapping[str, Any]) -> Dict[str, Dict[str, float]]:
71
+ """Return per-driver controls for one batched policy step."""
72
+ driver_ids = list(context.get("driverIds") or [])
73
+ return {driver_id: _zero_action() for driver_id in driver_ids}
74
+
75
+ async def publish_preview_frame(self, frame: Mapping[str, Any]) -> None:
76
+ """Optional extension hook called before broadcasting preview frames."""
77
+
78
+ # ----- Public helpers --------------------------------------------------
79
+
80
+ async def broadcast_preview_frame(
81
+ self,
82
+ snapshot: Mapping[str, Any],
83
+ observation: Mapping[str, Any],
84
+ meta: Optional[Mapping[str, Any]] = None,
85
+ ) -> None:
86
+ """Broadcast one authoritative preview frame to websocket clients."""
87
+ frame = {
88
+ "type": "preview:snapshot",
89
+ "snapshot": dict(snapshot),
90
+ "observation": dict(observation),
91
+ "meta": dict(meta or {}),
92
+ }
93
+ self.state.latest_preview_frame = frame
94
+ await self.publish_preview_frame(frame)
95
+ payload = frame
96
+ async with self._broadcast_lock:
97
+ stale: List[WebSocket] = []
98
+ for ws in self.state.connections:
99
+ try:
100
+ await ws.send_json(payload)
101
+ except Exception:
102
+ stale.append(ws)
103
+ if stale:
104
+ self.state.connections = [ws for ws in self.state.connections if ws not in stale]
105
+
106
+ # ----- Internal routing ------------------------------------------------
107
+
108
+ def _register_routes(self) -> None:
109
+ @self.app.get("/preview/frame")
110
+ async def preview_frame() -> JSONResponse:
111
+ if self.state.latest_preview_frame:
112
+ return JSONResponse(self.state.latest_preview_frame)
113
+ return JSONResponse(
114
+ {
115
+ "type": "preview:status",
116
+ "status": "waiting-for-frame",
117
+ "session": {"id": self.state.session_id},
118
+ }
119
+ )
120
+
121
+ @self.app.post("/policy/reset")
122
+ async def policy_reset(body: MutableMapping[str, Any]) -> JSONResponse:
123
+ self.state.session_id += 1
124
+ self.state.controlled_drivers = list(body.get("driverIds") or [])
125
+ await self.init_policy(body)
126
+ await self.reset_policy(body)
127
+ return JSONResponse(
128
+ {
129
+ "ok": True,
130
+ "session": {
131
+ "id": self.state.session_id,
132
+ "controlledDrivers": self.state.controlled_drivers,
133
+ },
134
+ }
135
+ )
136
+
137
+ @self.app.post("/policy/reset-state")
138
+ async def policy_reset_state(body: MutableMapping[str, Any]) -> JSONResponse:
139
+ driver_ids = list(body.get("driverIds") or [])
140
+ await self.reset_policy_state(driver_ids)
141
+ return JSONResponse(
142
+ {
143
+ "ok": True,
144
+ "session": {"id": self.state.session_id},
145
+ "resetDrivers": driver_ids,
146
+ }
147
+ )
148
+
149
+ @self.app.post("/policy/decide-batch")
150
+ async def policy_decide_batch(body: MutableMapping[str, Any]) -> JSONResponse:
151
+ actions = await self.decide_batch(body)
152
+ if not isinstance(actions, dict):
153
+ actions = {}
154
+ now_ms = int(time.time() * 1000)
155
+ return JSONResponse(
156
+ {
157
+ "ok": True,
158
+ "session": {"id": self.state.session_id},
159
+ "actions": actions,
160
+ "meta": {"serverTsMs": now_ms},
161
+ }
162
+ )
163
+
164
+ @self.app.websocket("/preview")
165
+ async def preview_stream(ws: WebSocket) -> None:
166
+ await ws.accept()
167
+ self.state.connections.append(ws)
168
+ try:
169
+ await ws.send_json(
170
+ {
171
+ "type": "preview:status",
172
+ "status": "connected",
173
+ "session": {"id": self.state.session_id},
174
+ }
175
+ )
176
+ while True:
177
+ # Keep connection alive; ignore client messages.
178
+ await ws.receive_text()
179
+ except WebSocketDisconnect:
180
+ pass
181
+ finally:
182
+ self.state.connections = [conn for conn in self.state.connections if conn is not ws]
183
+
184
+
185
+ def run(host: str = "127.0.0.1", port: int = 8787) -> None:
186
+ server = BasePolicyServer()
187
+ uvicorn.run(server.app, host=host, port=port, log_level="info")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ run()
@@ -0,0 +1,2 @@
1
+ fastapi
2
+ uvicorn