@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.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 (72) hide show
  1. package/README.md +20 -0
  2. package/dist/browser.d.ts +4 -1
  3. package/dist/browser.js +1 -0
  4. package/dist/checksum-basis.d.ts +4 -0
  5. package/dist/checksum-basis.js +17 -0
  6. package/dist/cjs/browser.js +4 -2
  7. package/dist/cjs/checksum-basis.js +22 -0
  8. package/dist/cjs/collider-cooking.js +834 -0
  9. package/dist/cjs/deterministic-session.js +2 -0
  10. package/dist/cjs/engine-parity-matrix.js +32 -9
  11. package/dist/cjs/engine-parity.js +1 -1
  12. package/dist/cjs/index.js +8 -2
  13. package/dist/cjs/instant-replay.js +4 -9
  14. package/dist/cjs/movement3d.js +17 -1
  15. package/dist/cjs/networked-client.js +11 -9
  16. package/dist/cjs/node.js +5 -1
  17. package/dist/cjs/offline-session.js +52 -24
  18. package/dist/cjs/physics-cert.js +7 -2
  19. package/dist/cjs/physics3d-hull.js +381 -0
  20. package/dist/cjs/physics3d-joints.js +163 -31
  21. package/dist/cjs/physics3d-recipes.js +197 -0
  22. package/dist/cjs/physics3d-shared.js +6 -0
  23. package/dist/cjs/physics3d-solver.js +746 -89
  24. package/dist/cjs/physics3d-stacking.js +154 -0
  25. package/dist/cjs/physics3d-toi.js +251 -0
  26. package/dist/cjs/physics3d-vehicle.js +35 -10
  27. package/dist/cjs/physics3d.js +401 -20
  28. package/dist/cjs/runtime-support.js +71 -0
  29. package/dist/cjs/session-promotion.js +5 -5
  30. package/dist/cjs/synctest.js +49 -0
  31. package/dist/cjs/testing.js +4 -1
  32. package/dist/cli.js +163 -41
  33. package/dist/collider-cooking.d.ts +141 -0
  34. package/dist/collider-cooking.js +829 -0
  35. package/dist/deterministic-session.js +2 -0
  36. package/dist/engine-parity-matrix.js +32 -9
  37. package/dist/engine-parity.js +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.js +1 -0
  40. package/dist/instant-replay.d.ts +2 -2
  41. package/dist/instant-replay.js +4 -9
  42. package/dist/movement3d.js +18 -2
  43. package/dist/networked-client.js +11 -9
  44. package/dist/node.d.ts +2 -0
  45. package/dist/node.js +2 -0
  46. package/dist/offline-session.js +53 -25
  47. package/dist/physics-cert.js +7 -2
  48. package/dist/physics3d-hull.d.ts +53 -0
  49. package/dist/physics3d-hull.js +376 -0
  50. package/dist/physics3d-joints.d.ts +7 -0
  51. package/dist/physics3d-joints.js +163 -31
  52. package/dist/physics3d-recipes.d.ts +29 -0
  53. package/dist/physics3d-recipes.js +193 -0
  54. package/dist/physics3d-shared.js +6 -0
  55. package/dist/physics3d-solver.d.ts +65 -1
  56. package/dist/physics3d-solver.js +744 -89
  57. package/dist/physics3d-stacking.d.ts +26 -0
  58. package/dist/physics3d-stacking.js +147 -0
  59. package/dist/physics3d-toi.d.ts +70 -0
  60. package/dist/physics3d-toi.js +245 -0
  61. package/dist/physics3d-vehicle.js +35 -10
  62. package/dist/physics3d.d.ts +20 -2
  63. package/dist/physics3d.js +400 -21
  64. package/dist/runtime-support.d.ts +60 -0
  65. package/dist/runtime-support.js +66 -0
  66. package/dist/session-promotion.js +5 -5
  67. package/dist/synctest.d.ts +1 -1
  68. package/dist/synctest.js +49 -0
  69. package/dist/testing.d.ts +2 -0
  70. package/dist/testing.js +1 -0
  71. package/dist/types.d.ts +21 -2
  72. package/package.json +8 -3
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDeterministicTower3DScenario = runDeterministicTower3DScenario;
4
+ exports.runDeterministicPyramid3DScenario = runDeterministicPyramid3DScenario;
5
+ exports.runDeterministicMassRatio3DScenario = runDeterministicMassRatio3DScenario;
6
+ exports.runDeterministicKickRecovery3DScenario = runDeterministicKickRecovery3DScenario;
7
+ exports.runDeterministicStackingSuite3D = runDeterministicStackingSuite3D;
8
+ const canonical_js_1 = require("./canonical.js");
9
+ const physics3d_js_1 = require("./physics3d.js");
10
+ const STACK_GRAVITY_Y = -0.02;
11
+ function stackFloor() {
12
+ return {
13
+ id: 'floor', kind: 'static', x: 0, y: -1, z: 0, vx: 0, vy: 0, vz: 0,
14
+ shape: { type: 'box', halfX: 40, halfY: 1, halfZ: 40 }, layer: 1, mask: 1,
15
+ material: { friction: 10, restitution: 0 },
16
+ };
17
+ }
18
+ function stackBox(id, x, y, mass, half) {
19
+ return {
20
+ id, kind: 'dynamic', x, y, z: 0, vx: 0, vy: 0, vz: 0,
21
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
22
+ shape: { type: 'box', halfX: half, halfY: 1, halfZ: half }, layer: 1, mask: 1,
23
+ material: { friction: 8, restitution: 0 }, mass,
24
+ };
25
+ }
26
+ // Tilt is reported as the up-axis dot product (1 = upright): no Math.acos —
27
+ // transcendentals are banned in src/ by the static determinism checker. Tests
28
+ // convert degree thresholds to cosines (1° → 0.99985, 3° → 0.99863).
29
+ function bodyUpDot(body) {
30
+ /* istanbul ignore next -- every dynamic scenario body is authored with an orientation. */
31
+ const q = body.orientation ?? { x: 0, y: 0, z: 0, w: 1 };
32
+ return 1 - 2 * (q.x * q.x + q.z * q.z);
33
+ }
34
+ function runScenario(spec) {
35
+ const runOnce = (bodies) => {
36
+ let world = (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies);
37
+ const snapshotFrame = Math.floor(spec.frames / 2);
38
+ let snapshot = world;
39
+ for (let frame = 0; frame < spec.frames; frame += 1) {
40
+ if (frame === snapshotFrame) {
41
+ snapshot = (0, canonical_js_1.cloneCanonical)(world);
42
+ }
43
+ if (spec.impulseAt !== undefined && frame === spec.impulseAt.frame) {
44
+ world = (0, physics3d_js_1.applyDeterministicImpulse3D)(world, spec.impulseAt.bodyId, spec.impulseAt.impulse, spec.impulseAt.point);
45
+ }
46
+ world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: STACK_GRAVITY_Y, computeChecksum: false }).world;
47
+ }
48
+ return { world, snapshot, snapshotFrame };
49
+ };
50
+ const main = runOnce(spec.bodies);
51
+ // rollback: resume from the mid-run snapshot and replay the identical suffix
52
+ let restored = main.snapshot;
53
+ for (let frame = main.snapshotFrame; frame < spec.frames; frame += 1) {
54
+ if (spec.impulseAt !== undefined && frame === spec.impulseAt.frame) {
55
+ restored = (0, physics3d_js_1.applyDeterministicImpulse3D)(restored, spec.impulseAt.bodyId, spec.impulseAt.impulse, spec.impulseAt.point);
56
+ }
57
+ restored = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(restored, { gravityY: STACK_GRAVITY_Y, computeChecksum: false }).world;
58
+ }
59
+ const permuted = runOnce([...spec.bodies].reverse());
60
+ const dyn = main.world.bodies.filter((body) => body.kind === 'dynamic');
61
+ let minUpDot = 1;
62
+ let maxXZDrift = 0;
63
+ let sleepingBodies = 0;
64
+ const initialById = new Map(spec.bodies.map((body) => [body.id, body]));
65
+ for (const body of dyn) {
66
+ const initial = initialById.get(body.id);
67
+ minUpDot = Math.min(minUpDot, bodyUpDot(body));
68
+ maxXZDrift = Math.max(maxXZDrift, Math.abs(body.x - initial.x), Math.abs(body.z - initial.z));
69
+ sleepingBodies += body.sleeping === true ? 1 : 0;
70
+ }
71
+ const topBody = dyn.find((body) => body.id === spec.topBodyId);
72
+ const topYError = Math.abs(topBody.y - spec.expectedY.get(spec.topBodyId));
73
+ const sortedByExpected = [...spec.expectedY.entries()].sort((l, r) => l[1] - r[1]).map(([id]) => id);
74
+ const finalYById = new Map(dyn.map((body) => [body.id, body.y]));
75
+ let verticalOrderPreserved = true;
76
+ for (let index = 1; index < sortedByExpected.length; index += 1) {
77
+ const below = finalYById.get(sortedByExpected[index - 1]);
78
+ const above = finalYById.get(sortedByExpected[index]);
79
+ verticalOrderPreserved = verticalOrderPreserved && above > below;
80
+ }
81
+ const details = (0, physics3d_js_1.contactDetailsDeterministicPhysics3D)(main.world);
82
+ const maxPenetration = details.reduce((max, contact) => Math.max(max, contact.penetration), 0);
83
+ const checksum = (0, canonical_js_1.defaultChecksum)(main.world);
84
+ return {
85
+ scenario: spec.scenario,
86
+ frames: spec.frames,
87
+ dynamicBodies: dyn.length,
88
+ minUpDot,
89
+ maxPenetration,
90
+ maxXZDrift,
91
+ topYError,
92
+ sleepingBodies,
93
+ verticalOrderPreserved,
94
+ checksum,
95
+ rollbackEquivalent: checksum === (0, canonical_js_1.defaultChecksum)(restored),
96
+ stableUnderAuthoringOrderPermutation: checksum === (0, canonical_js_1.defaultChecksum)(permuted.world),
97
+ };
98
+ }
99
+ function runDeterministicTower3DScenario(layers = 15, frames = 900) {
100
+ const bodies = [stackFloor(), ...Array.from({ length: layers }, (_, layer) => stackBox(`t${String(layer).padStart(2, '0')}`, 0, 1 + layer * 2, 1, 1))];
101
+ const expectedY = new Map(Array.from({ length: layers }, (_, layer) => [`t${String(layer).padStart(2, '0')}`, 1 + layer * 2]));
102
+ return runScenario({ scenario: `tower-${layers}`, bodies, frames, expectedY, topBodyId: `t${String(layers - 1).padStart(2, '0')}` });
103
+ }
104
+ function runDeterministicPyramid3DScenario(base = 10, frames = 600) {
105
+ const bodies = [stackFloor()];
106
+ const expectedY = new Map();
107
+ let id = 0;
108
+ let topId = '';
109
+ for (let layer = 0; layer < base; layer += 1) {
110
+ const count = base - layer;
111
+ for (let column = 0; column < count; column += 1) {
112
+ const bodyId = `p${String(id).padStart(3, '0')}`;
113
+ bodies.push(stackBox(bodyId, (column - (count - 1) / 2) * 2.05, 1 + layer * 2, 1, 1));
114
+ expectedY.set(bodyId, 1 + layer * 2);
115
+ topId = bodyId;
116
+ id += 1;
117
+ }
118
+ }
119
+ return runScenario({ scenario: `pyramid-${base}`, bodies, frames, expectedY, topBodyId: topId });
120
+ }
121
+ function runDeterministicMassRatio3DScenario(ratio = 50, frames = 900) {
122
+ const bodies = [
123
+ stackFloor(),
124
+ stackBox('b0', 0, 1, 1, 1), stackBox('b1', 0, 3, 1, 1), stackBox('b2', 0, 5, 1, 1),
125
+ stackBox('heavy', 0, 7.2, ratio, 1.5),
126
+ ];
127
+ const expectedY = new Map([['b0', 1], ['b1', 3], ['b2', 5], ['heavy', 7.2]]);
128
+ return runScenario({ scenario: `mass-ratio-${ratio}`, bodies, frames, expectedY, topBodyId: 'heavy' });
129
+ }
130
+ /**
131
+ * `impulseFrame` past the mid-run snapshot puts the kick inside the *replayed*
132
+ * suffix, so rollback equivalence proves the impulse is re-applied rather than
133
+ * merely baked into the snapshot.
134
+ */
135
+ function runDeterministicKickRecovery3DScenario(layers = 8, frames = 900, impulseFrame = 400) {
136
+ const bodies = [stackFloor(), ...Array.from({ length: layers }, (_, layer) => stackBox(`t${layer}`, 0, 1 + layer * 2, 1, 1))];
137
+ const expectedY = new Map(Array.from({ length: layers }, (_, layer) => [`t${layer}`, 1 + layer * 2]));
138
+ return runScenario({
139
+ scenario: `kick-recovery-${layers}`,
140
+ bodies,
141
+ frames,
142
+ impulseAt: { frame: impulseFrame, bodyId: 't3', impulse: { x: 0.2, y: 0, z: 0 }, point: { x: 0, y: 7, z: 0 } },
143
+ expectedY,
144
+ topBodyId: `t${layers - 1}`,
145
+ });
146
+ }
147
+ function runDeterministicStackingSuite3D() {
148
+ return [
149
+ runDeterministicTower3DScenario(),
150
+ runDeterministicPyramid3DScenario(),
151
+ runDeterministicMassRatio3DScenario(),
152
+ runDeterministicKickRecovery3DScenario(),
153
+ ];
154
+ }
@@ -0,0 +1,251 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.__toiInternals = void 0;
4
+ exports.toiProxyOf = toiProxyOf;
5
+ exports.gjkCoreDistance = gjkCoreDistance;
6
+ exports.conservativeToi = conservativeToi;
7
+ const physics3d_shared_js_1 = require("./physics3d-shared.js");
8
+ /**
9
+ * Build a TOI proxy for a solver shape at (pos, q). Returns null for shapes GJK does
10
+ * not handle here (mesh, compound) — the caller then leaves the body to the discrete
11
+ * solver (no TOI clamp).
12
+ */
13
+ function toiProxyOf(shape, pos, q) {
14
+ if (shape.kind === 'box') {
15
+ const h = shape.half;
16
+ const verts = [];
17
+ for (const sx of [-h.x, h.x]) {
18
+ for (const sy of [-h.y, h.y]) {
19
+ for (const sz of [-h.z, h.z]) {
20
+ verts.push((0, physics3d_shared_js_1.addVec3)(pos, (0, physics3d_shared_js_1.rotatePointByQuat)(q, { x: sx, y: sy, z: sz })));
21
+ }
22
+ }
23
+ }
24
+ return { verts, radius: 0 };
25
+ }
26
+ if (shape.kind === 'sphere') {
27
+ return { verts: [pos], radius: shape.radius };
28
+ }
29
+ if (shape.kind === 'capsule') {
30
+ const axis = (0, physics3d_shared_js_1.rotatePointByQuat)(q, { x: 0, y: shape.halfHeight, z: 0 });
31
+ return { verts: [(0, physics3d_shared_js_1.addVec3)(pos, axis), (0, physics3d_shared_js_1.subVec3)(pos, axis)], radius: shape.radius };
32
+ }
33
+ if (shape.kind === 'hull') {
34
+ return { verts: shape.data.vertices.map((v) => (0, physics3d_shared_js_1.addVec3)(pos, (0, physics3d_shared_js_1.rotatePointByQuat)(q, v))), radius: 0 };
35
+ }
36
+ return null;
37
+ }
38
+ function support(verts, dir) {
39
+ let best = 0;
40
+ let bestDot = (0, physics3d_shared_js_1.dotVec3)(verts[0], dir);
41
+ for (let i = 1; i < verts.length; i += 1) {
42
+ const d = (0, physics3d_shared_js_1.dotVec3)(verts[i], dir);
43
+ if (d > bestDot) {
44
+ bestDot = d;
45
+ best = i;
46
+ }
47
+ }
48
+ return best;
49
+ }
50
+ /** Closest point of a 1–4 vertex simplex (their `w`) to the origin, with barycentric weights. */
51
+ function closestToOrigin(s) {
52
+ if (s.length === 1) {
53
+ return { point: s[0].w, keep: [0], bary: [1] };
54
+ }
55
+ if (s.length === 2) {
56
+ return segmentClosest(s[0].w, s[1].w, [0, 1]);
57
+ }
58
+ if (s.length === 3) {
59
+ return triangleClosest(s[0].w, s[1].w, s[2].w, [0, 1, 2]);
60
+ }
61
+ return tetraClosest(s[0].w, s[1].w, s[2].w, s[3].w);
62
+ }
63
+ function segmentClosest(a, b, idx) {
64
+ const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
65
+ const t = -(0, physics3d_shared_js_1.dotVec3)(a, ab);
66
+ const denom = (0, physics3d_shared_js_1.dotVec3)(ab, ab);
67
+ if (t <= 0 || denom === 0) {
68
+ return { point: a, keep: [idx[0]], bary: [1] };
69
+ }
70
+ if (t >= denom) {
71
+ return { point: b, keep: [idx[1]], bary: [1] };
72
+ }
73
+ const u = t / denom;
74
+ return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ab, u)), keep: [idx[0], idx[1]], bary: [1 - u, u] };
75
+ }
76
+ // Closest point of triangle (a,b,c) to the origin (Ericson RTCD, specialized to p=0),
77
+ // returning barycentric weights over the retained vertices.
78
+ function triangleClosest(a, b, c, idx) {
79
+ const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
80
+ const ac = (0, physics3d_shared_js_1.subVec3)(c, a);
81
+ const ap = (0, physics3d_shared_js_1.scaleVec3)(a, -1);
82
+ const d1 = (0, physics3d_shared_js_1.dotVec3)(ab, ap);
83
+ const d2 = (0, physics3d_shared_js_1.dotVec3)(ac, ap);
84
+ if (d1 <= 0 && d2 <= 0) {
85
+ return { point: a, keep: [idx[0]], bary: [1] };
86
+ }
87
+ const bp = (0, physics3d_shared_js_1.scaleVec3)(b, -1);
88
+ const d3 = (0, physics3d_shared_js_1.dotVec3)(ab, bp);
89
+ const d4 = (0, physics3d_shared_js_1.dotVec3)(ac, bp);
90
+ if (d3 >= 0 && d4 <= d3) {
91
+ return { point: b, keep: [idx[1]], bary: [1] };
92
+ }
93
+ const vc = d1 * d4 - d3 * d2;
94
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) {
95
+ const v = d1 / (d1 - d3);
96
+ return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ab, v)), keep: [idx[0], idx[1]], bary: [1 - v, v] };
97
+ }
98
+ const cp = (0, physics3d_shared_js_1.scaleVec3)(c, -1);
99
+ const d5 = (0, physics3d_shared_js_1.dotVec3)(ab, cp);
100
+ const d6 = (0, physics3d_shared_js_1.dotVec3)(ac, cp);
101
+ if (d6 >= 0 && d5 <= d6) {
102
+ return { point: c, keep: [idx[2]], bary: [1] };
103
+ }
104
+ const vb = d5 * d2 - d1 * d6;
105
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) {
106
+ const w = d2 / (d2 - d6);
107
+ return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ac, w)), keep: [idx[0], idx[2]], bary: [1 - w, w] };
108
+ }
109
+ const va = d3 * d6 - d5 * d4;
110
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
111
+ const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
112
+ const bc = (0, physics3d_shared_js_1.subVec3)(c, b);
113
+ return { point: (0, physics3d_shared_js_1.addVec3)(b, (0, physics3d_shared_js_1.scaleVec3)(bc, w)), keep: [idx[1], idx[2]], bary: [1 - w, w] };
114
+ }
115
+ const denom = 1 / (va + vb + vc);
116
+ const v = vb * denom;
117
+ const w = vc * denom;
118
+ return {
119
+ point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(ab, v), (0, physics3d_shared_js_1.scaleVec3)(ac, w))),
120
+ keep: [idx[0], idx[1], idx[2]],
121
+ bary: [1 - v - w, v, w],
122
+ };
123
+ }
124
+ function outsidePlane(p, a, b, c, d) {
125
+ const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
126
+ const ac = (0, physics3d_shared_js_1.subVec3)(c, a);
127
+ const n = { x: ab.y * ac.z - ab.z * ac.y, y: ab.z * ac.x - ab.x * ac.z, z: ab.x * ac.y - ab.y * ac.x };
128
+ const signP = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(p, a), n);
129
+ const signD = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(d, a), n);
130
+ return signP * signD < 0;
131
+ }
132
+ // Closest point of tetrahedron (a,b,c,d) to the origin (Ericson RTCD, specialized to p=0).
133
+ function tetraClosest(a, b, c, d) {
134
+ const origin = { x: 0, y: 0, z: 0 };
135
+ let best = { point: origin, keep: [0, 1, 2, 3], bary: [0, 0, 0, 0] };
136
+ let bestDist2 = Infinity;
137
+ let anyOutside = false;
138
+ const faces = [
139
+ [0, 1, 2, 3],
140
+ [0, 2, 3, 1],
141
+ [0, 3, 1, 2],
142
+ [1, 3, 2, 0],
143
+ ];
144
+ const verts = [a, b, c, d];
145
+ for (const [i0, i1, i2, i3] of faces) {
146
+ if (outsidePlane(origin, verts[i0], verts[i1], verts[i2], verts[i3])) {
147
+ anyOutside = true;
148
+ const res = triangleClosest(verts[i0], verts[i1], verts[i2], [i0, i1, i2]);
149
+ const dist2 = (0, physics3d_shared_js_1.dotVec3)(res.point, res.point);
150
+ if (dist2 < bestDist2) {
151
+ bestDist2 = dist2;
152
+ best = res;
153
+ }
154
+ }
155
+ }
156
+ if (!anyOutside) {
157
+ return { point: origin, keep: [0, 1, 2, 3], bary: [0, 0, 0, 0] };
158
+ }
159
+ return best;
160
+ }
161
+ const GJK_MAX_ITERS = 24;
162
+ const GJK_EPS = 1e-10;
163
+ /** GJK closest distance between two convex cores (proxy vertices, ignoring radii). */
164
+ function gjkCoreDistance(vertsA, vertsB) {
165
+ let dir = (0, physics3d_shared_js_1.subVec3)(vertsA[0], vertsB[0]);
166
+ if ((0, physics3d_shared_js_1.dotVec3)(dir, dir) < GJK_EPS) {
167
+ dir = { x: 1, y: 0, z: 0 };
168
+ }
169
+ const mink = (d) => {
170
+ const a = vertsA[support(vertsA, d)];
171
+ const b = vertsB[support(vertsB, (0, physics3d_shared_js_1.scaleVec3)(d, -1))];
172
+ return { w: (0, physics3d_shared_js_1.subVec3)(a, b), a };
173
+ };
174
+ let simplex = [mink((0, physics3d_shared_js_1.scaleVec3)(dir, -1))];
175
+ let closest = simplex[0].w;
176
+ for (let iter = 0; iter < GJK_MAX_ITERS; iter += 1) {
177
+ const res = closestToOrigin(simplex);
178
+ closest = res.point;
179
+ const dist2 = (0, physics3d_shared_js_1.dotVec3)(closest, closest);
180
+ if (dist2 < GJK_EPS) {
181
+ return { distance: 0, normal: { x: 0, y: 0, z: 0 }, overlap: true };
182
+ }
183
+ simplex = res.keep.map((i) => simplex[i]);
184
+ const searchDir = (0, physics3d_shared_js_1.scaleVec3)(closest, -1);
185
+ const v = mink(searchDir);
186
+ // No further progress toward the origin ⇒ `closest` is the nearest point.
187
+ if ((0, physics3d_shared_js_1.dotVec3)(v.w, searchDir) - (0, physics3d_shared_js_1.dotVec3)(closest, searchDir) < GJK_EPS) {
188
+ break;
189
+ }
190
+ /* istanbul ignore next -- a repeated support vertex makes the progress test above zero, so this cycle guard never fires first. */
191
+ if (simplex.some((s) => (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(s.w, v.w), (0, physics3d_shared_js_1.subVec3)(s.w, v.w)) < GJK_EPS)) {
192
+ break;
193
+ }
194
+ simplex.push(v);
195
+ }
196
+ const dist = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(closest, closest));
197
+ /* istanbul ignore next -- a zero `closest` returns early as an overlap, so `dist` is positive here. */
198
+ const inv = dist > 0 ? -1 / dist : 0;
199
+ return { distance: dist, normal: (0, physics3d_shared_js_1.scaleVec3)(closest, inv), overlap: false };
200
+ }
201
+ const TOI_MAX_ITERS = 20;
202
+ const TOI_OVERLAP = { overlapping: true };
203
+ /**
204
+ * Linear conservative-advancement time-of-impact: how far the `fast` proxy may translate
205
+ * along unit `dir` (up to `maxDist`) before its surface first touches the stationary
206
+ * `target` proxy (within `targetSep`), plus the contact normal. Returns `ToiOverlap` if
207
+ * they already touch at the step start (the discrete solver owns a touching pair, but the
208
+ * caller must not advance the fast body *past* it), or undefined if they never touch
209
+ * within `maxDist` (a clean graze / miss).
210
+ */
211
+ function conservativeToi(fast, dirX, dirY, dirZ, maxDist, target, targetSep) {
212
+ const dir = { x: dirX, y: dirY, z: dirZ };
213
+ const radiusSum = fast.radius + target.radius;
214
+ let s = 0;
215
+ // Fallback contact normal (opposes motion) until a GJK normal is captured.
216
+ let hitNormal = { x: -dirX, y: -dirY, z: -dirZ };
217
+ for (let iter = 0; iter < TOI_MAX_ITERS; iter += 1) {
218
+ const moved = fast.verts.map((v) => ({ x: v.x + dirX * s, y: v.y + dirY * s, z: v.z + dirZ * s }));
219
+ const gjk = gjkCoreDistance(target.verts, moved);
220
+ if (gjk.overlap) {
221
+ // A corner/edge closest feature gives a slightly off-axis normal, so a step can
222
+ // overshoot a hair into overlap; `s` is still a valid first contact (penetration
223
+ // below the advancement step). An overlap at s=0 means already touching at the
224
+ // start — a blocker the caller must not advance past.
225
+ return s <= 0 ? TOI_OVERLAP : { distance: s, normal: hitNormal };
226
+ }
227
+ const sep = gjk.distance - radiusSum;
228
+ if (sep <= targetSep) {
229
+ return s <= 0 ? TOI_OVERLAP : { distance: s, normal: gjk.normal };
230
+ }
231
+ hitNormal = gjk.normal;
232
+ // The target is `sep` away in every direction, so advancing the sweep by `sep`
233
+ // along `dir` can never cross it (fully conservative — no overshoot even when the
234
+ // closest feature is a corner giving an off-axis normal). Bail early if the sweep
235
+ // is moving away from the contact (grazing / separating).
236
+ if (-(0, physics3d_shared_js_1.dotVec3)(dir, gjk.normal) <= GJK_EPS) {
237
+ return undefined;
238
+ }
239
+ s += sep;
240
+ if (s >= maxDist) {
241
+ return undefined;
242
+ }
243
+ }
244
+ return undefined;
245
+ }
246
+ /**
247
+ * Simplex sub-routines, exposed for direct testing. GJK only ever builds a
248
+ * simplex whose closest feature is interior, so the endpoint/vertex clamps
249
+ * inside these are unreachable through `gjkCoreDistance` alone.
250
+ */
251
+ exports.__toiInternals = { segmentClosest, triangleClosest, tetraClosest };
@@ -10,9 +10,12 @@ exports.runDeterministicVehicle3DFixture = runDeterministicVehicle3DFixture;
10
10
  * stateless controller reads the chassis pose/velocity from the world, casts
11
11
  * each wheel, computes spring-damper suspension + friction-circle tire
12
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).
13
+ * `applyDeterministicImpulse3D` before the next physics step. Suspension travel
14
+ * and tire slip are measured relative to the contacted ground body's surface
15
+ * velocity (v + ω×r), so moving platforms drag the car; every chassis impulse
16
+ * against a *dynamic* ground body emits an equal-and-opposite reaction on it
17
+ * (Newton's third law). Static/kinematic ground absorbs the reaction. The flat
18
+ * ground-normal assumption is unchanged.
16
19
  */
17
20
  const canonical_js_1 = require("./canonical.js");
18
21
  const math_js_1 = require("./math.js");
@@ -131,13 +134,18 @@ function castWheel(world, chassis, wheel) {
131
134
  // No exclude-id query knob exists; post-filter the distance-sorted hits.
132
135
  const hit = hits.find((candidate) => candidate.bodyId !== chassis.id);
133
136
  if (hit === undefined) {
134
- return { grounded: false, compression: 0, groundFriction: 0, anchor, suspensionUp };
137
+ return { grounded: false, compression: 0, groundFriction: 0, groundVelAtPoint: { x: 0, y: 0, z: 0 }, anchor, suspensionUp };
135
138
  }
136
139
  const groundBody = (0, physics3d_js_1.deterministicPhysicsBodyById3D)(world, hit.bodyId);
137
140
  // hit.distance <= restLength + radius (the ray's maxDistance), so the raw
138
141
  // compression is never negative — only the maxTravel clamp is needed.
139
142
  const rawCompression = wheel.suspensionRestLength - (hit.distance - wheel.radius);
140
143
  const compression = (0, physics3d_shared_js_1.quantizeDistance)(rawCompression > wheel.suspensionMaxTravel ? wheel.suspensionMaxTravel : rawCompression);
144
+ // Ground-surface velocity at the contact point (v + ω×r); a moving conveyor or
145
+ // floating raft drags/reacts through it. Static ground contributes nothing.
146
+ const groundVelAtPoint = groundBody.kind === 'static'
147
+ ? { x: 0, y: 0, z: 0 }
148
+ : (0, physics3d_shared_js_1.addVec3)({ x: groundBody.vx, y: groundBody.vy, z: groundBody.vz }, (0, physics3d_shared_js_1.crossVec3)(groundBody.angularVel ?? { x: 0, y: 0, z: 0 }, (0, physics3d_shared_js_1.subVec3)(hit.point, { x: groundBody.x, y: groundBody.y, z: groundBody.z })));
141
149
  return {
142
150
  grounded: compression > 0,
143
151
  compression,
@@ -145,6 +153,8 @@ function castWheel(world, chassis, wheel) {
145
153
  point: hit.point,
146
154
  normal: hit.normal,
147
155
  groundFriction: groundBody.material?.friction ?? 0,
156
+ groundKind: groundBody.kind,
157
+ groundVelAtPoint,
148
158
  anchor,
149
159
  suspensionUp,
150
160
  };
@@ -173,8 +183,14 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
173
183
  chassisVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.scaleVec3)(impulseVec, invMass));
174
184
  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
185
  };
176
- /** Quantize and emit an impulse; returns the quantized impulse (undefined if it rounds to zero). */
177
- const emitImpulse = (impulseVec, point) => {
186
+ /**
187
+ * Quantize and emit an impulse on the chassis; returns the quantized impulse
188
+ * (undefined if it rounds to zero). When `reactionBodyId` is set (a dynamic
189
+ * ground body), emits the equal-and-opposite reaction on it — Newton's third
190
+ * law so a conveyor/raft feels the car. Reaction entries are NOT fed through
191
+ * `applyDelta` (that tracks only the chassis's internal velocity state).
192
+ */
193
+ const emitImpulse = (impulseVec, point, reactionBodyId) => {
178
194
  const quantized = {
179
195
  x: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.x),
180
196
  y: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.y),
@@ -184,6 +200,9 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
184
200
  return undefined;
185
201
  }
186
202
  impulses.push({ bodyId: vehicle.chassisId, impulse: quantized, point });
203
+ if (reactionBodyId !== undefined) {
204
+ impulses.push({ bodyId: reactionBodyId, impulse: { x: -quantized.x, y: -quantized.y, z: -quantized.z }, point });
205
+ }
187
206
  return quantized;
188
207
  };
189
208
  // Phase A — suspension for every wheel before any tire force, so tire slip is
@@ -196,11 +215,13 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
196
215
  return 0;
197
216
  }
198
217
  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);
218
+ // Compression rate is measured relative to the ground surface velocity so a
219
+ // moving platform doesn't read as spurious suspension travel.
220
+ const compressionVelocity = -(0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(anchorVel, contact.groundVelAtPoint), contact.suspensionUp);
200
221
  const rawForce = wheel.stiffness * contact.compression + wheel.damping * compressionVelocity;
201
222
  const suspensionForce = rawForce > 0 ? rawForce : 0;
202
223
  const suspensionImpulseMag = suspensionForce * dt;
203
- const emitted = emitImpulse((0, physics3d_shared_js_1.scaleVec3)(contact.normal, suspensionImpulseMag), contact.point);
224
+ const emitted = emitImpulse((0, physics3d_shared_js_1.scaleVec3)(contact.normal, suspensionImpulseMag), contact.point, contact.groundKind === 'dynamic' ? contact.bodyId : undefined);
204
225
  if (emitted !== undefined) {
205
226
  applyDelta(emitted, contact.point);
206
227
  }
@@ -247,6 +268,8 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
247
268
  effLateralMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, sideDir),
248
269
  effForwardMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, forwardDir),
249
270
  maxTireImpulse: ((wheel.grip * contact.groundFriction) / 10) * suspensionImpulseMag,
271
+ groundVelAtPoint: contact.groundVelAtPoint,
272
+ reactionBodyId: contact.groundKind === 'dynamic' ? contact.bodyId : undefined,
250
273
  longitudinal: 0,
251
274
  lateral: 0,
252
275
  // brake + rolling resistance oppose the forward speed, spent from a fixed
@@ -258,7 +281,9 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
258
281
  }
259
282
  for (let iteration = 0; iteration < TIRE_ITERATIONS; iteration += 1) {
260
283
  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)));
284
+ // Slip is measured against the ground surface velocity so a conveyor drags
285
+ // the car and tire forces react back onto a dynamic ground body.
286
+ const contactVel = (0, physics3d_shared_js_1.subVec3)((0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.crossVec3)(angularVel, (0, physics3d_shared_js_1.subVec3)(tire.tirePoint, chassisPos))), tire.groundVelAtPoint);
262
287
  const forwardSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.forwardDir);
263
288
  const lateralSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.sideDir);
264
289
  if (iteration === 0) {
@@ -289,7 +314,7 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
289
314
  }
290
315
  }
291
316
  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);
317
+ 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, tire.reactionBodyId);
293
318
  }
294
319
  const wheels = vehicle.wheels.map((wheel, index) => {
295
320
  const contact = contacts[index];