@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
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stepDeterministicKcc3D = stepDeterministicKcc3D;
4
+ exports.stepDeterministicCarry3D = stepDeterministicCarry3D;
4
5
  exports.runDeterministicKcc3DFilterForceFixture = runDeterministicKcc3DFilterForceFixture;
5
6
  exports.runDeterministicKcc3DCallbackFilterFixture = runDeterministicKcc3DCallbackFilterFixture;
6
7
  exports.runDeterministicKcc3DCrowdFixture = runDeterministicKcc3DCrowdFixture;
@@ -8,6 +9,7 @@ exports.runDeterministicKcc3DAdversarialFixture = runDeterministicKcc3DAdversari
8
9
  exports.runDeterministicKcc3DGeometryFixture = runDeterministicKcc3DGeometryFixture;
9
10
  const canonical_js_1 = require("./canonical.js");
10
11
  const physics3d_js_1 = require("./physics3d.js");
12
+ const physics3d_shared_js_1 = require("./physics3d-shared.js");
11
13
  function stepDeterministicKcc3D(body, input, world, options) {
12
14
  let next = applyProcessors(body, input, options.frame, options.processors ?? []);
13
15
  const events = [];
@@ -82,6 +84,9 @@ function stepDeterministicKcc3D(body, input, world, options) {
82
84
  coyoteFrames = 0;
83
85
  events.push(next.grounded ? 'jump' : 'coyote-jump');
84
86
  }
87
+ // The KCC's intended (pre-slide) horizontal position and velocity — push
88
+ // impulses are computed here because the slide blocks the KCC off the body.
89
+ const pushCandidate = { x, z, vx, vz };
85
90
  if (options.physics !== undefined) {
86
91
  const resolved = resolvePhysicsSlide3D(options.physics, next, { x, y, z, vx, vz }, new Set(options.ignoredPhysicsBodyIds ?? []), options.frame, options.collisionFilters ?? []);
87
92
  physicsQueryCount += resolved.queryCount;
@@ -96,6 +101,37 @@ function stepDeterministicKcc3D(body, input, world, options) {
96
101
  events.push(resolved.swept ? 'physics-sweep' : resolved.slid ? 'physics-slide' : 'physics-correction');
97
102
  }
98
103
  }
104
+ const geometryForInteraction = normalizeKccGeometry3D(next);
105
+ let physicsImpulses = [];
106
+ // Stand on a dynamic body directly beneath the feet: attach as a platform and
107
+ // inherit its per-frame delta (its velocity), mirroring the kinematic path.
108
+ if (options.physics !== undefined && options.standOnDynamicBodies === true && vy <= 0) {
109
+ const ground = dynamicGroundUnderKcc(options.physics, x, y, z, geometryForInteraction, new Set(options.ignoredPhysicsBodyIds ?? []));
110
+ if (ground !== undefined) {
111
+ y = ground.topY;
112
+ vy = 0;
113
+ grounded = true;
114
+ platformId = ground.bodyId;
115
+ // Carry the rider with the crate this frame (position delta) AND inherit its
116
+ // velocity, mirroring the kinematic moving-platform path — otherwise the
117
+ // rider lags one frame and slides off a moving crate.
118
+ x += ground.deltaX;
119
+ z += ground.deltaZ;
120
+ vx += ground.deltaX;
121
+ vz += ground.deltaZ;
122
+ events.push('stand-on-dynamic');
123
+ if (ground.deltaX !== 0 || ground.deltaZ !== 0) {
124
+ events.push('platform-velocity-transfer');
125
+ }
126
+ }
127
+ }
128
+ // Push the dynamic bodies the KCC walked into this frame.
129
+ if (options.physics !== undefined && (options.maxPushImpulse ?? 0) > 0) {
130
+ physicsImpulses = computeKccPushImpulses(options.physics, pushCandidate.x, y, pushCandidate.z, { vx: pushCandidate.vx, vz: pushCandidate.vz }, geometryForInteraction, options.maxPushImpulse ?? 0, new Set(options.ignoredPhysicsBodyIds ?? []));
131
+ if (physicsImpulses.length > 0) {
132
+ events.push('push-dynamic');
133
+ }
134
+ }
99
135
  const boundsCorrection = clampBodyToBounds({ x, y, z, vx, vy, vz, grounded, coyoteFrames, jumpBufferFrames, platformId }, world);
100
136
  if (boundsCorrection.corrected) {
101
137
  correctionCount += boundsCorrection.corrections;
@@ -114,9 +150,202 @@ function stepDeterministicKcc3D(body, input, world, options) {
114
150
  physicsQueryCount,
115
151
  collisionFilterCalls,
116
152
  collisionFilterRejectedHits,
117
- checksum: (0, canonical_js_1.defaultChecksum)({ body: finalBody, events, correctionCount, physicsQueryCount, collisionFilterCalls, collisionFilterRejectedHits }),
153
+ physicsImpulses,
154
+ checksum: (0, canonical_js_1.defaultChecksum)({ body: finalBody, events, correctionCount, physicsQueryCount, collisionFilterCalls, collisionFilterRejectedHits, physicsImpulses }),
118
155
  };
119
156
  }
157
+ const KCC_PUSH_MASS = 1;
158
+ /** Dynamic physics bodies the KCC capsule overlaps at (x,y,z), sorted by id. */
159
+ function overlappingDynamicBodies(physics, x, y, z, geometry, ignoredBodyIds) {
160
+ const query = {
161
+ shape: { type: 'capsule', radius: geometry.radius + geometry.skinWidth, halfHeight: geometry.halfHeight },
162
+ x,
163
+ y,
164
+ z,
165
+ hitStatics: false,
166
+ hitDynamics: true,
167
+ hitKinematics: false,
168
+ };
169
+ const hitIds = new Set((0, physics3d_js_1.overlapDeterministicPhysics3D)(physics, query));
170
+ return physics.bodies
171
+ .filter((body) => body.kind === 'dynamic' && hitIds.has(body.id) && !ignoredBodyIds.has(body.id))
172
+ .sort((left, right) => compareStrings(left.id, right.id));
173
+ }
174
+ function computeKccPushImpulses(physics, x, y, z, velocity, geometry, maxPushImpulse, ignoredBodyIds) {
175
+ const impulses = [];
176
+ for (const body of overlappingDynamicBodies(physics, x, y, z, geometry, ignoredBodyIds)) {
177
+ // push along the horizontal direction from the KCC toward the body, scaled
178
+ // by the KCC's horizontal speed, capped by maxPushImpulse.
179
+ const toBodyX = body.x - x;
180
+ const toBodyZ = body.z - z;
181
+ const dirLen = deterministicKccSqrtLength(toBodyX, toBodyZ);
182
+ const dirX = dirLen === 0 ? (velocity.vx >= 0 ? 1 : -1) : toBodyX / dirLen;
183
+ const dirZ = dirLen === 0 ? 0 : toBodyZ / dirLen;
184
+ const speedIntoBody = Math.max(0, velocity.vx * dirX + velocity.vz * dirZ);
185
+ if (speedIntoBody === 0) {
186
+ continue;
187
+ }
188
+ const magnitude = Math.min(speedIntoBody * KCC_PUSH_MASS, maxPushImpulse);
189
+ impulses.push({
190
+ bodyId: body.id,
191
+ impulse: { x: dirX * magnitude, y: 0, z: dirZ * magnitude },
192
+ point: { x: body.x, y, z: body.z },
193
+ });
194
+ }
195
+ return impulses;
196
+ }
197
+ function deterministicKccSqrtLength(dx, dz) {
198
+ const squared = dx * dx + dz * dz;
199
+ if (squared <= 0) {
200
+ return 0;
201
+ }
202
+ let estimate = 1;
203
+ let magnitude = squared;
204
+ while (magnitude >= 4) {
205
+ magnitude /= 4;
206
+ estimate *= 2;
207
+ }
208
+ if (magnitude > 1) {
209
+ estimate *= 2;
210
+ }
211
+ for (let iteration = 0; iteration < 8; iteration += 1) {
212
+ estimate = (estimate + squared / estimate) / 2;
213
+ }
214
+ return estimate;
215
+ }
216
+ /**
217
+ * World-space Y half-extent of a ground body's top surface, honoring full
218
+ * quaternion orientation (the feet probe is orientation-aware, so the snap height
219
+ * must be too, or a tilted prop would float/sink). Spheres are orientation
220
+ * invariant; box/capsule project their local extents onto world +Y.
221
+ */
222
+ /**
223
+ * World-space top-surface Y of a body directly under the point (x,z), or
224
+ * undefined if (x,z) is outside the body's horizontal footprint (so a KCC beside
225
+ * a body — a side/wall contact — is not treated as standing on it). Compound
226
+ * bodies resolve per child, so an L-shaped prop reports the flat part's height
227
+ * when the KCC is over the flat part, not the tallest child's.
228
+ */
229
+ function groundContactTopAt(body, x, z) {
230
+ const q = body.orientation ?? { x: 0, y: 0, z: 0, w: 1 };
231
+ const shape = body.shape;
232
+ if (shape.type === 'box' || shape.type === 'sphere' || shape.type === 'capsule') {
233
+ const ext = primitiveHalfExtent(shape, (0, physics3d_shared_js_1.quatToMat3)(q));
234
+ if (Math.abs(x - body.x) > ext.x || Math.abs(z - body.z) > ext.z) {
235
+ return undefined;
236
+ }
237
+ return body.y + ext.y;
238
+ }
239
+ if (shape.type === 'compound') {
240
+ let top;
241
+ for (const child of shape.children) {
242
+ const center = { x: body.x, y: body.y, z: body.z };
243
+ const worldOffset = (0, physics3d_shared_js_1.rotatePointByQuat)(q, child.offset);
244
+ const childCenterX = center.x + worldOffset.x;
245
+ const childCenterZ = center.z + worldOffset.z;
246
+ const childQ = (0, physics3d_shared_js_1.quatMultiply)(q, (0, physics3d_shared_js_1.quaternionFromQuarterTurns)(child.quarterTurns ?? { x: 0, y: 0, z: 0 }));
247
+ const childExt = primitiveHalfExtent(child.shape, (0, physics3d_shared_js_1.quatToMat3)(childQ));
248
+ if (Math.abs(x - childCenterX) > childExt.x || Math.abs(z - childCenterZ) > childExt.z) {
249
+ continue;
250
+ }
251
+ const childTop = center.y + worldOffset.y + childExt.y;
252
+ top = top === undefined ? childTop : Math.max(top, childTop);
253
+ }
254
+ return top;
255
+ }
256
+ return undefined; // mesh: static-only, not a stand-on platform
257
+ }
258
+ /** World half-extents (x,y,z) of a primitive shape under rotation matrix `m`. */
259
+ function primitiveHalfExtent(shape, m) {
260
+ if (shape.type === 'sphere') {
261
+ return { x: shape.radius, y: shape.radius, z: shape.radius };
262
+ }
263
+ if (shape.type === 'box') {
264
+ return {
265
+ x: Math.abs(m[0]) * shape.halfX + Math.abs(m[1]) * shape.halfY + Math.abs(m[2]) * shape.halfZ,
266
+ y: Math.abs(m[3]) * shape.halfX + Math.abs(m[4]) * shape.halfY + Math.abs(m[5]) * shape.halfZ,
267
+ z: Math.abs(m[6]) * shape.halfX + Math.abs(m[7]) * shape.halfY + Math.abs(m[8]) * shape.halfZ,
268
+ };
269
+ }
270
+ // capsule: segment along local Y, spherical caps radius r
271
+ return {
272
+ x: Math.abs(m[1]) * shape.halfHeight + shape.radius,
273
+ y: Math.abs(m[4]) * shape.halfHeight + shape.radius,
274
+ z: Math.abs(m[7]) * shape.halfHeight + shape.radius,
275
+ };
276
+ }
277
+ /** A dynamic body directly under the KCC feet, with its per-frame delta. */
278
+ function dynamicGroundUnderKcc(physics, x, y, z, geometry, ignoredBodyIds) {
279
+ const feetY = y - geometry.halfHeight - geometry.skinWidth;
280
+ const probe = {
281
+ shape: { type: 'sphere', radius: geometry.radius },
282
+ x,
283
+ y: feetY,
284
+ z,
285
+ hitStatics: false,
286
+ hitDynamics: true,
287
+ hitKinematics: false,
288
+ };
289
+ const hitIds = new Set((0, physics3d_js_1.overlapDeterministicPhysics3D)(physics, probe));
290
+ // The KCC must stand OVER the body's horizontal footprint, not beside it —
291
+ // otherwise walking into a crate's side (a feet-level overlap with a body whose
292
+ // top towers above the feet) would vault the capsule onto the top.
293
+ const grounds = physics.bodies
294
+ .filter((body) => body.kind === 'dynamic' && hitIds.has(body.id) && !ignoredBodyIds.has(body.id))
295
+ .map((body) => ({ body, top: groundContactTopAt(body, x, z) }))
296
+ .filter((candidate) => candidate.top !== undefined)
297
+ .sort((left, right) => right.top - left.top || compareStrings(left.body.id, right.body.id));
298
+ const ground = grounds[0];
299
+ if (ground === undefined) {
300
+ return undefined;
301
+ }
302
+ return {
303
+ bodyId: ground.body.id,
304
+ topY: ground.top + geometry.halfHeight + geometry.skinWidth,
305
+ deltaX: ground.body.vx,
306
+ deltaZ: ground.body.vz,
307
+ };
308
+ }
309
+ const CARRY_GRAB_MAX_FORCE = 40;
310
+ /**
311
+ * Drive a `grab` joint target from a carrier pose (the pick-up/carry/throw loop).
312
+ * While held, returns the grab joint pointing at a hold point ahead of the
313
+ * carrier; on release, returns a throw impulse (carrier velocity + throw
314
+ * strength along forward).
315
+ */
316
+ function stepDeterministicCarry3D(carry, carrierPose) {
317
+ const forwardLen = deterministicKccSqrtLength3(carrierPose.forwardX, carrierPose.forwardY, carrierPose.forwardZ);
318
+ const fx = forwardLen === 0 ? 0 : carrierPose.forwardX / forwardLen;
319
+ const fy = forwardLen === 0 ? 0 : carrierPose.forwardY / forwardLen;
320
+ const fz = forwardLen === 0 ? 0 : carrierPose.forwardZ / forwardLen;
321
+ if (carry.released === true) {
322
+ return {
323
+ throwImpulse: {
324
+ bodyId: carry.bodyId,
325
+ impulse: {
326
+ x: carrierPose.vx + fx * carry.throwStrength,
327
+ y: carrierPose.vy + fy * carry.throwStrength,
328
+ z: carrierPose.vz + fz * carry.throwStrength,
329
+ },
330
+ },
331
+ };
332
+ }
333
+ return {
334
+ joint: {
335
+ id: `carry-${carry.bodyId}`,
336
+ type: 'grab',
337
+ body: carry.bodyId,
338
+ anchor: { x: 0, y: 0, z: 0 },
339
+ targetX: carrierPose.x + fx * carry.holdDistance,
340
+ targetY: carrierPose.y + fy * carry.holdDistance,
341
+ targetZ: carrierPose.z + fz * carry.holdDistance,
342
+ maxForce: CARRY_GRAB_MAX_FORCE,
343
+ },
344
+ };
345
+ }
346
+ function deterministicKccSqrtLength3(dx, dy, dz) {
347
+ return deterministicKccSqrtLength(deterministicKccSqrtLength(dx, dy), dz);
348
+ }
120
349
  function runDeterministicKcc3DFilterForceFixture() {
121
350
  const first = runKcc3DFilterForceCases();
122
351
  const second = runKcc3DFilterForceCases();
package/dist/cjs/node.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.stableEngineParityJson = exports.runEngineParitySuite = exports.explainEngineParityFixture = exports.runChaosCertificationSuite = exports.explainChaosFixture = exports.startLocalWebSocketProtocolServer = exports.runLocalWebSocketAdapterTest = exports.runNetworkedInputAuthoritySpike = exports.defaultNetworkedInputAuthoritySpikeOptions = exports.verifyReplay = exports.createSyncplaySession = exports.createOfflineSession = void 0;
17
+ exports.stableEngineParityJson = exports.runEngineParitySuite = exports.explainEngineParityFixture = exports.runChaosCertificationSuite = exports.explainChaosFixture = exports.startLocalWebSocketProtocolServer = exports.runLocalWebSocketAdapterTest = exports.runNetworkedInputAuthoritySpike = exports.defaultNetworkedInputAuthoritySpikeOptions = exports.verifyReplayScoreAgainstTrustedRuntime = exports.verifyReplay = exports.createSyncplaySession = exports.createOfflineSession = void 0;
18
18
  __exportStar(require("./multiplayer-service.js"), exports);
19
19
  __exportStar(require("./input-authority.js"), exports);
20
20
  __exportStar(require("./local-authority-wire.js"), exports);
@@ -31,6 +31,8 @@ __exportStar(require("./session-promotion.js"), exports);
31
31
  var replay_js_1 = require("./replay.js");
32
32
  Object.defineProperty(exports, "verifyReplay", { enumerable: true, get: function () { return replay_js_1.verifyReplay; } });
33
33
  __exportStar(require("./replay-scoring.js"), exports);
34
+ var replay_bundle_js_1 = require("./replay-bundle.js");
35
+ Object.defineProperty(exports, "verifyReplayScoreAgainstTrustedRuntime", { enumerable: true, get: function () { return replay_bundle_js_1.verifyReplayScoreAgainstTrustedRuntime; } });
34
36
  __exportStar(require("./match-log.js"), exports);
35
37
  __exportStar(require("./authority-room.js"), exports);
36
38
  __exportStar(require("./ws-frame.js"), exports);
@@ -37,6 +37,8 @@ exports.runPhysicsCert = runPhysicsCert;
37
37
  exports.runRapierPhysicsCert = runRapierPhysicsCert;
38
38
  const canonical_js_1 = require("./canonical.js");
39
39
  const collision_js_1 = require("./collision.js");
40
+ const physics3d_js_1 = require("./physics3d.js");
41
+ const physics3d_vehicle_js_1 = require("./physics3d-vehicle.js");
40
42
  const rapier2dCompatPackage = '@dimforge/rapier2d-compat';
41
43
  const physicsCertResults = {
42
44
  true: 'pass',
@@ -44,8 +46,9 @@ const physicsCertResults = {
44
46
  };
45
47
  function runPhysicsCert(options) {
46
48
  validateOptions(options);
47
- const uninterrupted = runPhysicsSimulation(options, false);
48
- const restored = runPhysicsSimulation(options, true);
49
+ const simulate = options.engine === 'builtin-rigid' ? runRigidSimulation : runPhysicsSimulation;
50
+ const uninterrupted = simulate(options, false);
51
+ const restored = simulate(options, true);
49
52
  const snapshotBytes = [...restored.snapshotBytes].sort((left, right) => left - right);
50
53
  const stepTimings = [...restored.stepTimings].sort((left, right) => left - right);
51
54
  const snapshotTimings = [...restored.snapshotTimings].sort((left, right) => left - right);
@@ -59,11 +62,11 @@ function runPhysicsCert(options) {
59
62
  snapshotHashStable;
60
63
  return {
61
64
  engine: options.engine,
62
- engineVersion: 'builtin-fixed@1',
65
+ engineVersion: options.engine === 'builtin-rigid' ? 'builtin-rigid@1' : 'builtin-fixed@1',
63
66
  adapterVersion: 'physics-cert@1',
64
67
  shape: options.shape,
65
68
  players: options.players,
66
- bodies: createInitialBodies(options).length,
69
+ bodies: options.engine === 'builtin-rigid' ? countRigidArenaBodies(options) : createInitialBodies(options).length,
67
70
  frames: options.frames,
68
71
  snapshotEvery: options.snapshotEvery,
69
72
  seed: options.seed,
@@ -267,6 +270,144 @@ function quantizeRapierNumber(value) {
267
270
  function countRapierDynamicBodies(options) {
268
271
  return options.players * (options.shape === 'fixed-arena' ? 2 : 4);
269
272
  }
273
+ function arenaVehicleWheel(localAnchor, steered) {
274
+ return {
275
+ localAnchor,
276
+ suspensionDir: { x: 0, y: -1, z: 0 },
277
+ suspensionRestLength: 0.6,
278
+ suspensionMaxTravel: 0.3,
279
+ radius: 0.3,
280
+ stiffness: 60,
281
+ damping: 220,
282
+ grip: 1,
283
+ maxSteerAngleTurns: 0.1,
284
+ isSteered: steered,
285
+ isDriven: steered,
286
+ };
287
+ }
288
+ /** Scripted arena driving: launch, weave, then brake to a stop. */
289
+ function arenaVehicleInput(frame) {
290
+ if (frame >= 400) {
291
+ return { throttle: 0, steer: 0, brake: 10 };
292
+ }
293
+ return { throttle: 10, steer: Math.trunc(frame / 32) % 2 === 0 ? 10 : -10, brake: 0 };
294
+ }
295
+ /**
296
+ * Mixed rigid arena exercising every solver feature the determinism gates must
297
+ * cover: a settling stack, a hinge door with a motor, a grab-target box, and a
298
+ * high-speed ccd projectile per player.
299
+ */
300
+ function buildRigidArena(options) {
301
+ const bodies = [{
302
+ id: 'arena-floor', kind: 'static', x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0,
303
+ shape: { type: 'box', halfX: 400, halfY: 0.5, halfZ: 400 }, layer: 1, mask: 1,
304
+ material: { friction: 10, restitution: 0 },
305
+ }];
306
+ const joints = [];
307
+ const ident = { x: 0, y: 0, z: 0, w: 1 };
308
+ for (let slot = 0; slot < options.players; slot += 1) {
309
+ const baseX = slot * 12 + (options.seed % 3);
310
+ // stack of 3 boxes
311
+ for (let layer = 0; layer < 3; layer += 1) {
312
+ bodies.push({
313
+ id: `stack-${slot}-${layer}`, kind: 'dynamic', x: baseX, y: 0.5 + 0.5 + layer, z: 0,
314
+ vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 },
315
+ shape: { type: 'box', halfX: 0.5, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1,
316
+ material: { friction: 8, restitution: 0 },
317
+ });
318
+ }
319
+ // hinge door
320
+ bodies.push({ id: `frame-${slot}`, kind: 'static', x: baseX + 3, y: 2, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.1, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1 });
321
+ bodies.push({ id: `door-${slot}`, kind: 'dynamic', x: baseX + 4, y: 2, z: 0, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 1, halfY: 0.5, halfZ: 0.1 }, layer: 1, mask: 1 });
322
+ joints.push({ id: `hinge-${slot}`, type: 'hinge', a: `frame-${slot}`, b: `door-${slot}`, anchorA: { x: 0, y: 0, z: 0 }, anchorB: { x: -1, y: 0, z: 0 }, axisA: { x: 0, y: 1, z: 0 }, motorSpeed: 0.02, maxMotorTorque: 50 });
323
+ // grab-target box
324
+ bodies.push({ id: `grab-${slot}`, kind: 'dynamic', x: baseX + 6, y: 3, z: 0, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 0.5, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1 });
325
+ joints.push({ id: `carry-${slot}`, type: 'grab', body: `grab-${slot}`, anchor: { x: 0, y: 0, z: 0 }, targetX: baseX + 6, targetY: 4, targetZ: 0, maxForce: 20 });
326
+ // ccd projectile toward a thin wall
327
+ bodies.push({ id: `wall-${slot}`, kind: 'static', x: baseX + 9, y: 2, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.05, halfY: 2, halfZ: 2 }, layer: 1, mask: 1 });
328
+ bodies.push({ id: `bullet-${slot}`, kind: 'dynamic', x: baseX - 20, y: 2, z: 0, vx: 60, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, ccd: true, linearDamping: 1, angularDamping: 1, shape: { type: 'box', halfX: 0.2, halfY: 0.2, halfZ: 0.2 }, layer: 1, mask: 1 });
329
+ // soft spring: bob hung 2 below a static anchor, restLength 1 → oscillates and settles
330
+ bodies.push({ id: `spring-anchor-${slot}`, kind: 'static', x: baseX + 2, y: 6, z: 3, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.1, halfY: 0.1, halfZ: 0.1 }, layer: 2, mask: 2 });
331
+ bodies.push({ id: `spring-bob-${slot}`, kind: 'dynamic', x: baseX + 2, y: 4, z: 3, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 0.3, halfY: 0.3, halfZ: 0.3 }, layer: 2, mask: 2 });
332
+ joints.push({ id: `spring-${slot}`, type: 'spring', a: `spring-anchor-${slot}`, b: `spring-bob-${slot}`, anchorA: { x: 0, y: 0, z: 0 }, anchorB: { x: 0, y: 0, z: 0 }, restLength: 1, frequencyHz: 2, dampingRatio: 0.5 });
333
+ }
334
+ // raycast-wheel vehicle: dropped onto the floor, then driven by a scripted
335
+ // input sequence (see arenaVehicleInput), clear of the per-slot clutter
336
+ bodies.push({
337
+ id: 'arena-car', kind: 'dynamic', x: -6, y: 2.55, z: 12,
338
+ vx: 0, vy: 0, vz: 0, mass: 1200, orientation: ident, angularVel: { x: 0, y: 0, z: 0 },
339
+ shape: { type: 'box', halfX: 1, halfY: 0.25, halfZ: 2 }, layer: 1, mask: 1,
340
+ });
341
+ const vehicle = {
342
+ chassisId: 'arena-car',
343
+ driveForce: 12,
344
+ brakeForce: 40,
345
+ wheels: [
346
+ arenaVehicleWheel({ x: -0.9, y: -0.25, z: 1.5 }, true),
347
+ arenaVehicleWheel({ x: 0.9, y: -0.25, z: 1.5 }, true),
348
+ arenaVehicleWheel({ x: -0.9, y: -0.25, z: -1.5 }, false),
349
+ arenaVehicleWheel({ x: 0.9, y: -0.25, z: -1.5 }, false),
350
+ ],
351
+ };
352
+ return { world: (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies), joints, vehicle };
353
+ }
354
+ function countRigidArenaBodies(options) {
355
+ return 2 + options.players * 10; // floor + arena car + (3 stack + frame + door + grab + wall + bullet + spring anchor + spring bob)
356
+ }
357
+ function runRigidSimulation(options, withRestore) {
358
+ const { world: initialWorld, joints, vehicle } = buildRigidArena(options);
359
+ let world = initialWorld;
360
+ const collisionEvents = [];
361
+ const stepTimings = [];
362
+ const snapshotTimings = [];
363
+ const restoreTimings = [];
364
+ const snapshotBytes = [];
365
+ const snapshotChecksums = [];
366
+ let queryOrderStable = true;
367
+ for (let frame = 0; frame < options.frames; frame += 1) {
368
+ const stepStartedAt = performance.now();
369
+ const drive = (0, physics3d_vehicle_js_1.stepDeterministicVehicle3D)(vehicle, arenaVehicleInput(frame), world, { dtTicks: 1 });
370
+ world = (0, physics3d_js_1.applyDeterministicImpulses3D)(world, drive.impulses.map((impulse) => ({
371
+ bodyId: impulse.bodyId,
372
+ impulse: impulse.impulse,
373
+ worldPoint: impulse.point,
374
+ })));
375
+ const step = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: -0.02, joints, computeChecksum: false });
376
+ world = step.world;
377
+ stepTimings.push(performance.now() - stepStartedAt);
378
+ for (const contact of step.contacts) {
379
+ collisionEvents.push(`${frame + 1}:${contact.pair}:${contact.type}`);
380
+ }
381
+ if (withRestore && frame % options.snapshotEvery === 0) {
382
+ const snapshotStartedAt = performance.now();
383
+ // snapshot the WHOLE world including contactCache/jointCache — dropping the
384
+ // cache here would diverge (the negative case the spike identified).
385
+ const snapshot = JSON.stringify(world);
386
+ snapshotTimings.push(performance.now() - snapshotStartedAt);
387
+ snapshotBytes.push(Buffer.byteLength(snapshot));
388
+ snapshotChecksums.push({ first: (0, canonical_js_1.defaultChecksum)(snapshot), second: (0, canonical_js_1.defaultChecksum)(JSON.stringify(world)) });
389
+ const restoreStartedAt = performance.now();
390
+ world = JSON.parse(snapshot);
391
+ restoreTimings.push(performance.now() - restoreStartedAt);
392
+ }
393
+ const hits = (0, physics3d_js_1.raycastDeterministicPhysics3D)(world, { x: -1000, y: 2, z: 0, dx: 1, dy: 0, dz: 0, maxDistance: 5000, layerMask: 1 });
394
+ for (let index = 1; index < hits.length; index += 1) {
395
+ if (hits[index].distance < hits[index - 1].distance) {
396
+ queryOrderStable = false;
397
+ }
398
+ }
399
+ }
400
+ return {
401
+ finalChecksum: (0, canonical_js_1.defaultChecksum)(world),
402
+ collisionEvents,
403
+ queryOrderStable,
404
+ stepTimings,
405
+ snapshotTimings,
406
+ restoreTimings,
407
+ snapshotBytes,
408
+ snapshotChecksums,
409
+ };
410
+ }
270
411
  function runPhysicsSimulation(options, withRestore) {
271
412
  let state = {
272
413
  frame: 0,
@@ -396,8 +537,8 @@ function physicsCertResult(pass) {
396
537
  return physicsCertResults[String(pass)];
397
538
  }
398
539
  function validateOptions(options) {
399
- if (options.engine !== 'builtin-fixed') {
400
- throw new Error('physics certification currently supports engine=builtin-fixed');
540
+ if (options.engine !== 'builtin-fixed' && options.engine !== 'builtin-rigid') {
541
+ throw new Error('physics certification currently supports engine=builtin-fixed or engine=builtin-rigid');
401
542
  }
402
543
  validateCommonOptions(options);
403
544
  }
@@ -272,18 +272,22 @@ function separateDynamicPair(a, b) {
272
272
  const correctionA = Math.ceil(overlapX / 2);
273
273
  const correctionB = Math.ceil(overlapX / 2);
274
274
  return {
275
- a: { ...a, x: a.x + direction * correctionA, vx: normalizeZero(Math.trunc((-a.vx * material.restitution) / 10)) },
276
- b: { ...b, x: b.x - direction * correctionB, vx: normalizeZero(Math.trunc((-b.vx * material.restitution) / 10)) },
275
+ a: { ...a, x: a.x + direction * correctionA, vx: normalizeZero(Math.trunc((-a.vx * material.restitution) / 10)), vy: applyTangentFriction2D(a.vy, material.friction) },
276
+ b: { ...b, x: b.x - direction * correctionB, vx: normalizeZero(Math.trunc((-b.vx * material.restitution) / 10)), vy: applyTangentFriction2D(b.vy, material.friction) },
277
277
  };
278
278
  }
279
279
  const direction = a.y <= b.y ? -1 : 1;
280
280
  const correctionA = Math.ceil(overlapY / 2);
281
281
  const correctionB = Math.ceil(overlapY / 2);
282
282
  return {
283
- a: { ...a, y: a.y + direction * correctionA, vy: normalizeZero(Math.trunc((-a.vy * material.restitution) / 10)) },
284
- b: { ...b, y: b.y - direction * correctionB, vy: normalizeZero(Math.trunc((-b.vy * material.restitution) / 10)) },
283
+ a: { ...a, y: a.y + direction * correctionA, vy: normalizeZero(Math.trunc((-a.vy * material.restitution) / 10)), vx: applyTangentFriction2D(a.vx, material.friction) },
284
+ b: { ...b, y: b.y - direction * correctionB, vy: normalizeZero(Math.trunc((-b.vy * material.restitution) / 10)), vx: applyTangentFriction2D(b.vx, material.friction) },
285
285
  };
286
286
  }
287
+ /** Integer tangential-friction scaling, mirroring physics3d's legacy pusher semantics. */
288
+ function applyTangentFriction2D(velocity, friction) {
289
+ return normalizeZero(Math.trunc((velocity * Math.max(0, 10 - friction)) / 10));
290
+ }
287
291
  function separateFromStatic(dynamicBody, staticBody) {
288
292
  const dynamicBounds = bodyBounds(dynamicBody);
289
293
  const staticBounds = bodyBounds(staticBody);
@@ -299,6 +303,7 @@ function separateFromStatic(dynamicBody, staticBody) {
299
303
  ...dynamicBody,
300
304
  x: dynamicBody.x + direction * overlapX,
301
305
  vx: normalizeZero(Math.trunc((-dynamicBody.vx * material.restitution) / 10)),
306
+ vy: applyTangentFriction2D(dynamicBody.vy, material.friction),
302
307
  };
303
308
  }
304
309
  const direction = dynamicBody.y < staticBody.y ? -1 : 1;
@@ -306,6 +311,7 @@ function separateFromStatic(dynamicBody, staticBody) {
306
311
  ...dynamicBody,
307
312
  y: dynamicBody.y + direction * overlapY,
308
313
  vy: normalizeZero(Math.trunc((-dynamicBody.vy * material.restitution) / 10)),
314
+ vx: applyTangentFriction2D(dynamicBody.vx, material.friction),
309
315
  };
310
316
  }
311
317
  function detectContacts(bodies) {