@series-inc/rundot-syncplay 5.23.0-beta.8 → 5.23.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 (53) hide show
  1. package/README.md +61 -6
  2. package/dist/browser.d.ts +86 -57
  3. package/dist/browser.js +46 -53
  4. package/dist/certification.d.ts +20 -3
  5. package/dist/certification.js +78 -2
  6. package/dist/cjs/browser.js +259 -90
  7. package/dist/cjs/certification.js +78 -2
  8. package/dist/cjs/creator.js +2 -1
  9. package/dist/cjs/engine-complete.js +213 -3
  10. package/dist/cjs/engine-parity-matrix.js +19 -4
  11. package/dist/cjs/index.js +193 -139
  12. package/dist/cjs/movement3d.js +230 -1
  13. package/dist/cjs/node.js +3 -1
  14. package/dist/cjs/physics-cert.js +147 -6
  15. package/dist/cjs/physics2d.js +10 -4
  16. package/dist/cjs/physics3d-joints.js +637 -0
  17. package/dist/cjs/physics3d-shared.js +288 -0
  18. package/dist/cjs/physics3d-solver.js +1351 -0
  19. package/dist/cjs/physics3d-vehicle.js +467 -0
  20. package/dist/cjs/physics3d.js +1057 -223
  21. package/dist/cjs/replay-bundle.js +127 -20
  22. package/dist/cjs/sample-scenes.js +3 -0
  23. package/dist/cjs/testing.js +107 -0
  24. package/dist/cli.js +17 -9
  25. package/dist/creator.d.ts +1 -1
  26. package/dist/creator.js +1 -1
  27. package/dist/engine-complete.js +214 -4
  28. package/dist/engine-parity-matrix.js +19 -4
  29. package/dist/index.d.ts +93 -90
  30. package/dist/index.js +44 -56
  31. package/dist/movement3d.d.ts +73 -0
  32. package/dist/movement3d.js +229 -1
  33. package/dist/node.d.ts +2 -0
  34. package/dist/node.js +1 -0
  35. package/dist/physics-cert.d.ts +1 -1
  36. package/dist/physics-cert.js +147 -6
  37. package/dist/physics2d.js +10 -4
  38. package/dist/physics3d-joints.d.ts +94 -0
  39. package/dist/physics3d-joints.js +634 -0
  40. package/dist/physics3d-shared.d.ts +72 -0
  41. package/dist/physics3d-shared.js +257 -0
  42. package/dist/physics3d-solver.d.ts +197 -0
  43. package/dist/physics3d-solver.js +1346 -0
  44. package/dist/physics3d-vehicle.d.ts +84 -0
  45. package/dist/physics3d-vehicle.js +463 -0
  46. package/dist/physics3d.d.ts +108 -8
  47. package/dist/physics3d.js +1006 -177
  48. package/dist/replay-bundle.d.ts +40 -1
  49. package/dist/replay-bundle.js +126 -20
  50. package/dist/sample-scenes.js +3 -0
  51. package/dist/testing.d.ts +52 -0
  52. package/dist/testing.js +45 -0
  53. package/package.json +11 -3
@@ -0,0 +1,467 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stepDeterministicVehicle3D = stepDeterministicVehicle3D;
4
+ exports.runDeterministicVehicle3DFixture = runDeterministicVehicle3DFixture;
5
+ /**
6
+ * Deterministic raycast-wheel vehicle controller on the rigid-body solver.
7
+ *
8
+ * A vehicle is one dynamic chassis body plus N suspension raycasts (wheels are
9
+ * rays, not bodies — Bullet/Godot raycast-vehicle lineage). Per step the
10
+ * stateless controller reads the chassis pose/velocity from the world, casts
11
+ * each wheel, computes spring-damper suspension + friction-circle tire
12
+ * impulses, and returns them for the game to apply via
13
+ * `applyDeterministicImpulse3D` before the next physics step. v1 is specified
14
+ * against static ground: the controller emits chassis-only impulses (no
15
+ * moving-ground relative velocity, no reaction impulse on the ground body).
16
+ */
17
+ const canonical_js_1 = require("./canonical.js");
18
+ const math_js_1 = require("./math.js");
19
+ const physics3d_js_1 = require("./physics3d.js");
20
+ const physics3d_shared_js_1 = require("./physics3d-shared.js");
21
+ const vehicleMath = (0, math_js_1.createDeterministicMath)();
22
+ /** Fraction of the suspension impulse lost to rolling resistance each step. */
23
+ const ROLLING_RESISTANCE = 0.02;
24
+ /** Gauss-Seidel passes over the wheels for the tire (drive/brake/grip) solve. */
25
+ const TIRE_ITERATIONS = 6;
26
+ const TWO_PI = 2 * Math.PI;
27
+ /** Chassis-local forward axis: vehicles drive along +z. */
28
+ const LOCAL_FORWARD = { x: 0, y: 0, z: 1 };
29
+ /** Rodrigues rotation of `v` about unit axis `axis` given cos/sin of the angle. */
30
+ function rotateAboutAxis(v, axis, cos, sin) {
31
+ const term1 = (0, physics3d_shared_js_1.scaleVec3)(v, cos);
32
+ const term2 = (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.crossVec3)(axis, v), sin);
33
+ const term3 = (0, physics3d_shared_js_1.scaleVec3)(axis, (0, physics3d_shared_js_1.dotVec3)(axis, v) * (1 - cos));
34
+ return (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.addVec3)(term1, term2), term3);
35
+ }
36
+ function isFiniteNumber(value) {
37
+ return typeof value === 'number' && Number.isFinite(value);
38
+ }
39
+ function assertIntegerInput(value, label, min, max) {
40
+ if (!Number.isInteger(value) || value < min || value > max) {
41
+ throw new Error(`stepDeterministicVehicle3D: ${label} must be an integer in [${min}, ${max}], received ${value}`);
42
+ }
43
+ }
44
+ function assertPositiveWheelField(value, wheelLabel, name) {
45
+ if (!isFiniteNumber(value) || value <= 0) {
46
+ throw new Error(`${wheelLabel}: ${name} must be a positive finite number`);
47
+ }
48
+ }
49
+ function validateVehicle3D(vehicle, input, world, options) {
50
+ const chassis = (0, physics3d_js_1.deterministicPhysicsBodyById3D)(world, vehicle.chassisId);
51
+ if (chassis === undefined) {
52
+ throw new Error(`stepDeterministicVehicle3D: unknown chassis body ${vehicle.chassisId}`);
53
+ }
54
+ // Point-impulse yaw is silently dropped by applyDeterministicImpulse3D for
55
+ // non-dynamic or orientation-less bodies, so steering would be a no-op.
56
+ if (chassis.kind !== 'dynamic' || chassis.orientation === undefined) {
57
+ throw new Error(`vehicle ${vehicle.chassisId}: chassis must be a dynamic body with an orientation quaternion`);
58
+ }
59
+ if (vehicle.wheels.length === 0) {
60
+ throw new Error(`vehicle ${vehicle.chassisId}: wheels must not be empty`);
61
+ }
62
+ for (const [name, value] of [['driveForce', vehicle.driveForce], ['brakeForce', vehicle.brakeForce]]) {
63
+ if (!isFiniteNumber(value) || value < 0) {
64
+ throw new Error(`vehicle ${vehicle.chassisId}: ${name} must be a non-negative finite number`);
65
+ }
66
+ }
67
+ for (let index = 0; index < vehicle.wheels.length; index += 1) {
68
+ const wheel = vehicle.wheels[index];
69
+ const wheelLabel = `vehicle ${vehicle.chassisId} wheel ${index}`;
70
+ for (const [name, point] of [['localAnchor', wheel.localAnchor], ['suspensionDir', wheel.suspensionDir]]) {
71
+ if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y) || !isFiniteNumber(point.z)) {
72
+ throw new Error(`${wheelLabel}: ${name} must have finite components`);
73
+ }
74
+ }
75
+ if (wheel.suspensionDir.x === 0 && wheel.suspensionDir.y === 0 && wheel.suspensionDir.z === 0) {
76
+ throw new Error(`${wheelLabel}: suspensionDir must be a non-zero vector`);
77
+ }
78
+ assertPositiveWheelField(wheel.radius, wheelLabel, 'radius');
79
+ assertPositiveWheelField(wheel.suspensionRestLength, wheelLabel, 'suspensionRestLength');
80
+ assertPositiveWheelField(wheel.suspensionMaxTravel, wheelLabel, 'suspensionMaxTravel');
81
+ assertPositiveWheelField(wheel.stiffness, wheelLabel, 'stiffness');
82
+ if (wheel.suspensionMaxTravel > wheel.suspensionRestLength) {
83
+ throw new Error(`${wheelLabel}: suspensionMaxTravel must not exceed suspensionRestLength`);
84
+ }
85
+ for (const [name, value] of [['damping', wheel.damping], ['grip', wheel.grip]]) {
86
+ if (!isFiniteNumber(value) || value < 0) {
87
+ throw new Error(`${wheelLabel}: ${name} must be a non-negative finite number`);
88
+ }
89
+ }
90
+ if (!isFiniteNumber(wheel.maxSteerAngleTurns) || wheel.maxSteerAngleTurns < 0 || wheel.maxSteerAngleTurns > 0.25) {
91
+ throw new Error(`${wheelLabel}: maxSteerAngleTurns must be within [0, 0.25]`);
92
+ }
93
+ }
94
+ assertIntegerInput(input.throttle, 'throttle', -10, 10);
95
+ assertIntegerInput(input.steer, 'steer', -10, 10);
96
+ assertIntegerInput(input.brake, 'brake', 0, 10);
97
+ if (options.dtTicks !== undefined && (!isFiniteNumber(options.dtTicks) || options.dtTicks <= 0)) {
98
+ throw new Error(`stepDeterministicVehicle3D: dtTicks must be a positive finite number, received ${options.dtTicks}`);
99
+ }
100
+ return chassis;
101
+ }
102
+ /**
103
+ * Effective mass of the chassis at lever arm `r` along direction `dir`
104
+ * (1 / (1/m + d·((I⁻¹(r×d))×r))) — the impulse that cancels the point velocity
105
+ * exactly, including the rotational coupling a plain mass share would ignore.
106
+ */
107
+ function effectiveMassAt(invMass, invInertiaWorld, r, dir) {
108
+ // k = invMass + quadratic form of the SPD inverse inertia: always > 0.
109
+ const rCrossDir = (0, physics3d_shared_js_1.crossVec3)(r, dir);
110
+ const k = invMass + (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.crossVec3)((0, physics3d_shared_js_1.mulMat3Vec)(invInertiaWorld, rCrossDir), r), dir);
111
+ return 1 / k;
112
+ }
113
+ function castWheel(world, chassis, wheel) {
114
+ const orientation = chassis.orientation;
115
+ const chassisPos = { x: chassis.x, y: chassis.y, z: chassis.z };
116
+ const anchor = (0, physics3d_shared_js_1.addVec3)(chassisPos, (0, physics3d_shared_js_1.rotatePointByQuat)(orientation, wheel.localAnchor));
117
+ const dir = (0, physics3d_shared_js_1.normalizeVec3)((0, physics3d_shared_js_1.rotatePointByQuat)(orientation, wheel.suspensionDir));
118
+ const suspensionUp = (0, physics3d_shared_js_1.scaleVec3)(dir, -1);
119
+ const hits = (0, physics3d_js_1.raycastDeterministicPhysics3DDetailed)(world, {
120
+ x: anchor.x,
121
+ y: anchor.y,
122
+ z: anchor.z,
123
+ dx: dir.x,
124
+ dy: dir.y,
125
+ dz: dir.z,
126
+ maxDistance: wheel.suspensionRestLength + wheel.radius,
127
+ hitTriggers: false,
128
+ hitSensors: false,
129
+ hitSolids: true,
130
+ });
131
+ // No exclude-id query knob exists; post-filter the distance-sorted hits.
132
+ const hit = hits.find((candidate) => candidate.bodyId !== chassis.id);
133
+ if (hit === undefined) {
134
+ return { grounded: false, compression: 0, groundFriction: 0, anchor, suspensionUp };
135
+ }
136
+ const groundBody = (0, physics3d_js_1.deterministicPhysicsBodyById3D)(world, hit.bodyId);
137
+ // hit.distance <= restLength + radius (the ray's maxDistance), so the raw
138
+ // compression is never negative — only the maxTravel clamp is needed.
139
+ const rawCompression = wheel.suspensionRestLength - (hit.distance - wheel.radius);
140
+ const compression = (0, physics3d_shared_js_1.quantizeDistance)(rawCompression > wheel.suspensionMaxTravel ? wheel.suspensionMaxTravel : rawCompression);
141
+ return {
142
+ grounded: compression > 0,
143
+ compression,
144
+ bodyId: hit.bodyId,
145
+ point: hit.point,
146
+ normal: hit.normal,
147
+ groundFriction: groundBody.material?.friction ?? 0,
148
+ anchor,
149
+ suspensionUp,
150
+ };
151
+ }
152
+ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
153
+ const chassis = validateVehicle3D(vehicle, input, world, options);
154
+ const dt = options.dtTicks ?? 1;
155
+ const contacts = vehicle.wheels.map((wheel) => castWheel(world, chassis, wheel));
156
+ const chassisPos = { x: chassis.x, y: chassis.y, z: chassis.z };
157
+ // Internal velocity state, advanced as impulses accumulate so each solve
158
+ // stage sees the effect of the previous ones.
159
+ let chassisVel = { x: chassis.vx, y: chassis.vy, z: chassis.vz };
160
+ let angularVel = chassis.angularVel ?? { x: 0, y: 0, z: 0 };
161
+ const orientation = chassis.orientation;
162
+ const forwardWorld = (0, physics3d_shared_js_1.rotatePointByQuat)(orientation, LOCAL_FORWARD);
163
+ const chassisMass = chassis.mass ?? 1;
164
+ const wheelCount = vehicle.wheels.length;
165
+ const drivenCount = vehicle.wheels.filter((wheel) => wheel.isDriven).length;
166
+ const invMass = 1 / chassisMass;
167
+ const invInertiaWorld = (0, physics3d_shared_js_1.worldInvInertia)((0, physics3d_shared_js_1.quatToMat3)(orientation), (0, physics3d_js_1.deriveInverseInertia)(chassis.shape, chassisMass));
168
+ const throttleFraction = input.throttle / 10;
169
+ const brakeFraction = input.brake / 10;
170
+ const steerFraction = input.steer / 10;
171
+ const impulses = [];
172
+ const applyDelta = (impulseVec, point) => {
173
+ chassisVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.scaleVec3)(impulseVec, invMass));
174
+ angularVel = (0, physics3d_shared_js_1.addVec3)(angularVel, (0, physics3d_shared_js_1.mulMat3Vec)(invInertiaWorld, (0, physics3d_shared_js_1.crossVec3)((0, physics3d_shared_js_1.subVec3)(point, chassisPos), impulseVec)));
175
+ };
176
+ /** Quantize and emit an impulse; returns the quantized impulse (undefined if it rounds to zero). */
177
+ const emitImpulse = (impulseVec, point) => {
178
+ const quantized = {
179
+ x: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.x),
180
+ y: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.y),
181
+ z: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.z),
182
+ };
183
+ if (quantized.x === 0 && quantized.y === 0 && quantized.z === 0) {
184
+ return undefined;
185
+ }
186
+ impulses.push({ bodyId: vehicle.chassisId, impulse: quantized, point });
187
+ return quantized;
188
+ };
189
+ // Phase A — suspension for every wheel before any tire force, so tire slip is
190
+ // measured against the settled post-suspension state (interleaving lets a
191
+ // wheel's slip cancel sample the lopsided half-applied suspension state and
192
+ // steers the car under symmetric input).
193
+ const suspensionImpulseMags = vehicle.wheels.map((wheel, index) => {
194
+ const contact = contacts[index];
195
+ if (!contact.grounded || contact.point === undefined || contact.normal === undefined) {
196
+ return 0;
197
+ }
198
+ const anchorVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.crossVec3)(angularVel, (0, physics3d_shared_js_1.subVec3)(contact.anchor, chassisPos)));
199
+ const compressionVelocity = -(0, physics3d_shared_js_1.dotVec3)(anchorVel, contact.suspensionUp);
200
+ const rawForce = wheel.stiffness * contact.compression + wheel.damping * compressionVelocity;
201
+ const suspensionForce = rawForce > 0 ? rawForce : 0;
202
+ const suspensionImpulseMag = suspensionForce * dt;
203
+ const emitted = emitImpulse((0, physics3d_shared_js_1.scaleVec3)(contact.normal, suspensionImpulseMag), contact.point);
204
+ if (emitted !== undefined) {
205
+ applyDelta(emitted, contact.point);
206
+ }
207
+ return suspensionImpulseMag;
208
+ });
209
+ const tireStates = [];
210
+ const spinDeltas = vehicle.wheels.map(() => 0);
211
+ for (let index = 0; index < vehicle.wheels.length; index += 1) {
212
+ const wheel = vehicle.wheels[index];
213
+ const contact = contacts[index];
214
+ if (!contact.grounded || contact.point === undefined || contact.normal === undefined) {
215
+ continue;
216
+ }
217
+ // Steer angle through the deterministic fixed-point trig instance: float
218
+ // turn fraction → fixed-point turns → sin/cos → quantized float.
219
+ const steerFixedTurns = wheel.isSteered
220
+ ? Math.round(steerFraction * wheel.maxSteerAngleTurns * vehicleMath.fixedScale)
221
+ : 0;
222
+ const sinSteer = (0, physics3d_shared_js_1.quantizeDistance)(vehicleMath.sinTurns(steerFixedTurns) / vehicleMath.fixedScale);
223
+ const cosSteer = (0, physics3d_shared_js_1.quantizeDistance)(vehicleMath.cosTurns(steerFixedTurns) / vehicleMath.fixedScale);
224
+ const steeredForward = steerFixedTurns === 0
225
+ ? forwardWorld
226
+ : rotateAboutAxis(forwardWorld, contact.suspensionUp, cosSteer, sinSteer);
227
+ const forwardPlanar = (0, physics3d_shared_js_1.subVec3)(steeredForward, (0, physics3d_shared_js_1.scaleVec3)(contact.normal, (0, physics3d_shared_js_1.dotVec3)(steeredForward, contact.normal)));
228
+ const forwardDir = (0, physics3d_shared_js_1.normalizeVec3)(forwardPlanar);
229
+ if (forwardDir.x === 0 && forwardDir.y === 0 && forwardDir.z === 0) {
230
+ continue;
231
+ }
232
+ // Tire impulses act at the contact point projected into the COM plane along
233
+ // the normal: the lever arm is then ⊥ normal, so lateral grip yields pure
234
+ // yaw with zero roll/pitch injection. Applying side friction at ground
235
+ // level couples into the (small-inertia) roll axis and the per-wheel slip
236
+ // cancels ping-pong into a self-propelling limit cycle — the same reason
237
+ // Bullet's raycast vehicle raises its side-impulse point (rollInfluence).
238
+ const tirePoint = (0, physics3d_shared_js_1.addVec3)(contact.point, (0, physics3d_shared_js_1.scaleVec3)(contact.normal, (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(chassisPos, contact.point), contact.normal)));
239
+ const sideDir = (0, physics3d_shared_js_1.normalizeVec3)((0, physics3d_shared_js_1.crossVec3)(contact.normal, forwardDir));
240
+ const leverArm = (0, physics3d_shared_js_1.subVec3)(tirePoint, chassisPos);
241
+ const suspensionImpulseMag = suspensionImpulseMags[index];
242
+ tireStates.push({
243
+ index,
244
+ forwardDir,
245
+ sideDir,
246
+ tirePoint,
247
+ effLateralMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, sideDir),
248
+ effForwardMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, forwardDir),
249
+ maxTireImpulse: ((wheel.grip * contact.groundFriction) / 10) * suspensionImpulseMag,
250
+ longitudinal: 0,
251
+ lateral: 0,
252
+ // brake + rolling resistance oppose the forward speed, spent from a fixed
253
+ // per-frame budget so iterations cannot over-apply them
254
+ resistBudget: (brakeFraction * vehicle.brakeForce * dt) / wheelCount
255
+ + ROLLING_RESISTANCE * suspensionImpulseMag,
256
+ driveBudget: wheel.isDriven ? (throttleFraction * vehicle.driveForce * dt) / drivenCount : 0,
257
+ });
258
+ }
259
+ for (let iteration = 0; iteration < TIRE_ITERATIONS; iteration += 1) {
260
+ for (const tire of tireStates) {
261
+ const contactVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.crossVec3)(angularVel, (0, physics3d_shared_js_1.subVec3)(tire.tirePoint, chassisPos)));
262
+ const forwardSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.forwardDir);
263
+ const lateralSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.sideDir);
264
+ if (iteration === 0) {
265
+ spinDeltas[tire.index] = (0, physics3d_shared_js_1.quantizeDistance)((forwardSpeed * dt) / (TWO_PI * vehicle.wheels[tire.index].radius));
266
+ }
267
+ const drive = tire.driveBudget;
268
+ tire.driveBudget = 0;
269
+ const absForwardSpeed = forwardSpeed < 0 ? -forwardSpeed : forwardSpeed;
270
+ const resistCap = absForwardSpeed * tire.effForwardMass;
271
+ const resistWanted = tire.resistBudget > resistCap ? resistCap : tire.resistBudget;
272
+ const resist = forwardSpeed > 0 ? resistWanted : forwardSpeed < 0 ? -resistWanted : 0;
273
+ tire.resistBudget -= resistWanted;
274
+ let longitudinal = tire.longitudinal + drive - resist;
275
+ let lateral = tire.lateral - lateralSpeed * tire.effLateralMass;
276
+ // friction circle in impulse units on the ACCUMULATED tire impulse:
277
+ // sqrt(long² + lat²) ≤ μ · suspension impulse
278
+ const tireMag = (0, physics3d_shared_js_1.solverSqrt)(longitudinal * longitudinal + lateral * lateral);
279
+ if (tireMag > tire.maxTireImpulse) {
280
+ // tireMag > maxTireImpulse >= 0 implies tireMag > 0
281
+ const scale = tire.maxTireImpulse / tireMag;
282
+ longitudinal *= scale;
283
+ lateral *= scale;
284
+ }
285
+ const delta = (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(tire.forwardDir, longitudinal - tire.longitudinal), (0, physics3d_shared_js_1.scaleVec3)(tire.sideDir, lateral - tire.lateral));
286
+ applyDelta(delta, tire.tirePoint);
287
+ tire.longitudinal = longitudinal;
288
+ tire.lateral = lateral;
289
+ }
290
+ }
291
+ for (const tire of tireStates) {
292
+ emitImpulse((0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(tire.forwardDir, tire.longitudinal), (0, physics3d_shared_js_1.scaleVec3)(tire.sideDir, tire.lateral)), tire.tirePoint);
293
+ }
294
+ const wheels = vehicle.wheels.map((wheel, index) => {
295
+ const contact = contacts[index];
296
+ const steerFixedTurns = wheel.isSteered
297
+ ? Math.round(steerFraction * wheel.maxSteerAngleTurns * vehicleMath.fixedScale)
298
+ : 0;
299
+ return {
300
+ grounded: contact.grounded,
301
+ compression: contact.compression,
302
+ steerAngleTurns: (0, physics3d_shared_js_1.quantizeDistance)(steerFixedTurns / vehicleMath.fixedScale),
303
+ contactBodyId: contact.grounded ? contact.bodyId : undefined,
304
+ contactPoint: contact.grounded ? contact.point : undefined,
305
+ contactNormal: contact.grounded ? contact.normal : undefined,
306
+ spinDeltaTurns: spinDeltas[index],
307
+ };
308
+ });
309
+ const events = [];
310
+ if (contacts.every((contact) => !contact.grounded)) {
311
+ events.push('airborne');
312
+ }
313
+ // Checksum covers only the load-bearing outputs — impulses, grounded flags,
314
+ // compressions, events. Cosmetic telemetry (contact point, spin) is excluded
315
+ // so it cannot false-diverge a replay.
316
+ const checksum = (0, canonical_js_1.defaultChecksum)({
317
+ impulses,
318
+ grounded: wheels.map((wheel) => wheel.grounded),
319
+ compressions: wheels.map((wheel) => wheel.compression),
320
+ events,
321
+ });
322
+ return { impulses, wheels, events, checksum };
323
+ }
324
+ const FIXTURE_GRAVITY_Y = -0.02;
325
+ function fixtureWheel(localAnchor, steered) {
326
+ return {
327
+ localAnchor,
328
+ suspensionDir: { x: 0, y: -1, z: 0 },
329
+ suspensionRestLength: 0.6,
330
+ suspensionMaxTravel: 0.3,
331
+ radius: 0.3,
332
+ stiffness: 60,
333
+ damping: 220,
334
+ grip: 1,
335
+ maxSteerAngleTurns: 0.1,
336
+ isSteered: steered,
337
+ isDriven: steered,
338
+ };
339
+ }
340
+ function fixtureVehicle(chassisId) {
341
+ return {
342
+ chassisId,
343
+ driveForce: 12,
344
+ brakeForce: 40,
345
+ wheels: [
346
+ fixtureWheel({ x: -0.9, y: -0.25, z: 1.5 }, true),
347
+ fixtureWheel({ x: 0.9, y: -0.25, z: 1.5 }, true),
348
+ fixtureWheel({ x: -0.9, y: -0.25, z: -1.5 }, false),
349
+ fixtureWheel({ x: 0.9, y: -0.25, z: -1.5 }, false),
350
+ ],
351
+ };
352
+ }
353
+ function fixtureBodies() {
354
+ const chassis = (id, x) => ({
355
+ id,
356
+ kind: 'dynamic',
357
+ x,
358
+ y: 1.55,
359
+ z: 0,
360
+ vx: 0,
361
+ vy: 0,
362
+ vz: 0,
363
+ mass: 1200,
364
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
365
+ shape: { type: 'box', halfX: 1, halfY: 0.25, halfZ: 2 },
366
+ layer: 1,
367
+ mask: 1,
368
+ });
369
+ return [
370
+ {
371
+ id: 'vehicle-ground',
372
+ kind: 'static',
373
+ x: 0,
374
+ y: 0,
375
+ z: 0,
376
+ vx: 0,
377
+ vy: 0,
378
+ vz: 0,
379
+ shape: { type: 'box', halfX: 60, halfY: 0.5, halfZ: 60 },
380
+ layer: 1,
381
+ mask: 1,
382
+ material: { friction: 8, restitution: 0 },
383
+ },
384
+ chassis('vehicle-car-a', -4),
385
+ chassis('vehicle-car-b', 4),
386
+ ];
387
+ }
388
+ function fixtureInput(frame, chassisId) {
389
+ const phase = chassisId === 'vehicle-car-a' ? 0 : 1;
390
+ return {
391
+ throttle: frame < 80 ? 10 : 0,
392
+ steer: (Math.trunc(frame / 20) + phase) % 2 === 0 ? 10 : -10,
393
+ brake: frame >= 100 ? 10 : 0,
394
+ };
395
+ }
396
+ function impulseComponentClean(value) {
397
+ return Number.isFinite(value) && !Object.is(value, -0);
398
+ }
399
+ function runVehicleFixtureLoop(initialWorld, vehicleOrder, frames, priorFrameChecksums = []) {
400
+ let world = initialWorld;
401
+ const frameChecksums = [...priorFrameChecksums];
402
+ const snapshotFrame = initialWorld.frame + Math.trunc(frames / 2);
403
+ let snapshot = initialWorld;
404
+ let impulseSamples = 0;
405
+ let groundedWheelSamples = 0;
406
+ let impulsesQuantizedClean = true;
407
+ for (let frame = 0; frame < frames; frame += 1) {
408
+ if (world.frame === snapshotFrame) {
409
+ snapshot = (0, canonical_js_1.cloneCanonical)(world);
410
+ }
411
+ // Step vehicles in the given (possibly permuted) order; each step is a pure
412
+ // read of the same world. Impulses are applied in canonical chassis-id
413
+ // order so authoring order cannot leak into the outcome.
414
+ const results = vehicleOrder.map((vehicle) => ({
415
+ vehicle,
416
+ result: stepDeterministicVehicle3D(vehicle, fixtureInput(world.frame, vehicle.chassisId), world, { dtTicks: 1 }),
417
+ }));
418
+ // chassis ids are unique (normalizeBodies enforces world-wide uniqueness)
419
+ const canonical = [...results].sort((left, right) => (left.vehicle.chassisId < right.vehicle.chassisId ? -1 : 1));
420
+ for (const entry of canonical) {
421
+ impulseSamples += entry.result.impulses.length;
422
+ groundedWheelSamples += entry.result.wheels.filter((wheel) => wheel.grounded).length;
423
+ impulsesQuantizedClean = impulsesQuantizedClean && entry.result.impulses.every((impulse) => impulseComponentClean(impulse.impulse.x)
424
+ && impulseComponentClean(impulse.impulse.y)
425
+ && impulseComponentClean(impulse.impulse.z));
426
+ world = (0, physics3d_js_1.applyDeterministicImpulses3D)(world, entry.result.impulses.map((impulse) => ({
427
+ bodyId: impulse.bodyId,
428
+ impulse: impulse.impulse,
429
+ worldPoint: impulse.point,
430
+ })));
431
+ }
432
+ world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: FIXTURE_GRAVITY_Y, computeChecksum: false }).world;
433
+ frameChecksums.push((0, canonical_js_1.defaultChecksum)({
434
+ frame: world.frame,
435
+ vehicles: canonical.map((entry) => entry.result.checksum),
436
+ }));
437
+ }
438
+ return { world, frameChecksums, snapshot, snapshotFrame, impulseSamples, groundedWheelSamples, impulsesQuantizedClean };
439
+ }
440
+ /**
441
+ * Determinism fixture for the raycast vehicle: the full read→apply→step loop is
442
+ * bit-identical across a mid-run snapshot/restore and across world body /
443
+ * vehicle authoring-order permutation (wheel arrays are authored and fixed —
444
+ * wheel-order permutation is intentionally NOT part of the contract).
445
+ */
446
+ function runDeterministicVehicle3DFixture(frames = 120) {
447
+ const bodies = fixtureBodies();
448
+ const vehicles = [fixtureVehicle('vehicle-car-a'), fixtureVehicle('vehicle-car-b')];
449
+ const run = runVehicleFixtureLoop((0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies), vehicles, frames);
450
+ const restored = runVehicleFixtureLoop(run.snapshot, vehicles, frames - run.snapshotFrame, run.frameChecksums.slice(0, run.snapshotFrame));
451
+ const permuted = runVehicleFixtureLoop((0, physics3d_js_1.createDeterministicPhysicsWorld3D)([...bodies].reverse()), [...vehicles].reverse(), frames);
452
+ const checksum = (0, canonical_js_1.defaultChecksum)({ world: run.world, frameChecksums: run.frameChecksums });
453
+ const restoredChecksum = (0, canonical_js_1.defaultChecksum)({ world: restored.world, frameChecksums: restored.frameChecksums });
454
+ const permutedChecksum = (0, canonical_js_1.defaultChecksum)({ world: permuted.world, frameChecksums: permuted.frameChecksums });
455
+ return {
456
+ checksum,
457
+ restoredChecksum,
458
+ permutedChecksum,
459
+ rollbackEquivalent: checksum === restoredChecksum,
460
+ stableUnderAuthoringOrderPermutation: checksum === permutedChecksum,
461
+ frames,
462
+ vehicles: vehicles.length,
463
+ impulseSamples: run.impulseSamples,
464
+ groundedWheelSamples: run.groundedWheelSamples,
465
+ impulsesQuantizedClean: run.impulsesQuantizedClean,
466
+ };
467
+ }