@series-inc/rundot-syncplay 5.24.0-beta.1 → 5.24.0-beta.2

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 (48) hide show
  1. package/README.md +11 -4
  2. package/dist/authority-room.d.ts +6 -0
  3. package/dist/authority-room.js +5 -1
  4. package/dist/browser.d.ts +7 -3
  5. package/dist/browser.js +3 -1
  6. package/dist/cjs/authority-room.js +5 -1
  7. package/dist/cjs/browser.js +9 -2
  8. package/dist/cjs/cosmetic-physics3d.js +201 -0
  9. package/dist/cjs/creator.js +26 -1
  10. package/dist/cjs/engine-complete.js +9 -4
  11. package/dist/cjs/engine-parity.js +5 -2
  12. package/dist/cjs/index.js +12 -2
  13. package/dist/cjs/input-authority.js +33 -1
  14. package/dist/cjs/math.js +30 -0
  15. package/dist/cjs/networked-client.js +37 -4
  16. package/dist/cjs/physics3d-destruction-cert.js +279 -0
  17. package/dist/cjs/physics3d-solver.js +95 -32
  18. package/dist/cjs/physics3d-voxel.js +94 -0
  19. package/dist/cjs/physics3d.js +121 -3
  20. package/dist/cjs/sdk-session.js +1 -0
  21. package/dist/cjs/session-wire.js +12 -6
  22. package/dist/cli.js +47 -3
  23. package/dist/cosmetic-physics3d.d.ts +41 -0
  24. package/dist/cosmetic-physics3d.js +196 -0
  25. package/dist/creator.d.ts +8 -0
  26. package/dist/creator.js +7 -0
  27. package/dist/engine-complete.js +9 -4
  28. package/dist/engine-parity.js +5 -2
  29. package/dist/index.d.ts +8 -2
  30. package/dist/index.js +4 -1
  31. package/dist/input-authority.d.ts +3 -0
  32. package/dist/input-authority.js +33 -1
  33. package/dist/math.d.ts +4 -0
  34. package/dist/math.js +30 -0
  35. package/dist/networked-client.d.ts +9 -0
  36. package/dist/networked-client.js +37 -4
  37. package/dist/physics3d-destruction-cert.d.ts +35 -0
  38. package/dist/physics3d-destruction-cert.js +276 -0
  39. package/dist/physics3d-solver.d.ts +26 -1
  40. package/dist/physics3d-solver.js +94 -32
  41. package/dist/physics3d-voxel.d.ts +20 -0
  42. package/dist/physics3d-voxel.js +91 -0
  43. package/dist/physics3d.d.ts +30 -2
  44. package/dist/physics3d.js +120 -3
  45. package/dist/sdk-session.d.ts +2 -0
  46. package/dist/sdk-session.js +1 -0
  47. package/dist/session-wire.js +12 -6
  48. package/package.json +5 -4
@@ -11,6 +11,7 @@ function createInputAuthority(config) {
11
11
  const retainedConfirmedFrames = config.retainedConfirmedFrames ?? 512;
12
12
  const pending = new Map();
13
13
  const pendingCommands = new Map();
14
+ const occupiedSlotsByTick = new Map();
14
15
  const lastConfirmedInput = Array.from({ length: playerCount }, () => config.neutralInput);
15
16
  const retained = new Map();
16
17
  let confirmedThrough = -1;
@@ -23,6 +24,19 @@ function createInputAuthority(config) {
23
24
  const frame = frames[index];
24
25
  (0, errors_js_1.invariant)(Number.isInteger(frame.tick) && (index === 0 || frame.tick === frames[index - 1].tick + 1), 'restoreFrom frames must be tick-contiguous', 'input-authority.restore-gap');
25
26
  (0, errors_js_1.invariant)(frame.inputs.length === playerCount, 'restoreFrom frame inputs must cover every player slot', 'input-authority.restore-inputs');
27
+ if (frame.occupiedSlots !== undefined) {
28
+ (0, errors_js_1.invariant)(frame.occupiedSlots.every((slot, slotIndex, slots) => (Number.isInteger(slot) &&
29
+ slot >= 0 &&
30
+ slot < playerCount &&
31
+ (slotIndex === 0 || slots[slotIndex - 1] < slot))), 'restoreFrom occupied slots must be sorted unique valid slots', 'input-authority.restore-occupied-slots');
32
+ const checksumPayload = {
33
+ tick: frame.tick,
34
+ inputs: frame.inputs,
35
+ occupiedSlots: frame.occupiedSlots,
36
+ ...((frame.commands?.length ?? 0) === 0 ? {} : { commands: frame.commands }),
37
+ };
38
+ (0, errors_js_1.invariant)(frame.checksum === String((0, canonical_js_1.defaultChecksum)(checksumPayload)), 'restoreFrom occupied-slot checksum mismatch', 'input-authority.restore-occupied-checksum');
39
+ }
26
40
  retained.set(frame.tick, (0, canonical_js_1.cloneCanonical)(frame));
27
41
  }
28
42
  (0, errors_js_1.invariant)(frames[frames.length - 1].tick === restoredThrough, 'restoreFrom frames must end at the restored frontier', 'input-authority.restore-frontier');
@@ -80,12 +94,21 @@ function createInputAuthority(config) {
80
94
  .map((command) => (0, canonical_js_1.cloneCanonical)(command))
81
95
  .sort(compareAuthorityCommands);
82
96
  pendingCommands.delete(tick);
97
+ const occupiedSlots = occupiedSlotsByTick.get(tick);
98
+ occupiedSlotsByTick.delete(tick);
99
+ const checksumPayload = {
100
+ tick,
101
+ inputs,
102
+ ...(occupiedSlots === undefined ? {} : { occupiedSlots }),
103
+ ...(commands.length === 0 ? {} : { commands }),
104
+ };
83
105
  const frame = {
84
106
  tick,
85
107
  inputs,
86
108
  substitutedSlots,
109
+ ...(occupiedSlots === undefined ? {} : { occupiedSlots }),
87
110
  ...(commands.length === 0 ? {} : { commands }),
88
- checksum: String((0, canonical_js_1.defaultChecksum)(commands.length === 0 ? { tick, inputs } : { tick, inputs, commands })),
111
+ checksum: String((0, canonical_js_1.defaultChecksum)(checksumPayload)),
89
112
  };
90
113
  retained.set(tick, frame);
91
114
  if (earliestRetained === -1) {
@@ -122,6 +145,15 @@ function createInputAuthority(config) {
122
145
  return confirmedThrough;
123
146
  },
124
147
  receiveInput,
148
+ recordOccupiedSlots(tick, slots) {
149
+ (0, errors_js_1.invariant)(Number.isInteger(tick) && tick >= 0, 'presence tick must be a non-negative integer', 'input-authority.presence-tick');
150
+ const sorted = [...slots].sort((a, b) => a - b);
151
+ (0, errors_js_1.invariant)(sorted.every((slot, index) => (Number.isInteger(slot) &&
152
+ slot >= 0 &&
153
+ slot < playerCount &&
154
+ (index === 0 || sorted[index - 1] !== slot))), 'occupied slots must be unique valid slots', 'input-authority.presence-slots');
155
+ occupiedSlotsByTick.set(tick, sorted);
156
+ },
125
157
  queueCommand(tick, command) {
126
158
  (0, errors_js_1.invariant)(Number.isInteger(tick) && tick >= 0, 'command tick must be a non-negative integer', 'input-authority.command-tick');
127
159
  (0, errors_js_1.invariant)(tick > confirmedThrough, `command tick ${tick} is already confirmed`, 'input-authority.command-confirmed');
package/dist/cjs/math.js CHANGED
@@ -130,6 +130,25 @@ function createDeterministicMath(fixedScale = 1_000, minFixed = DEFAULT_MIN_FIXE
130
130
  }
131
131
  return saturate(x, minFixed, maxFixed);
132
132
  },
133
+ integerSqrt(value) {
134
+ if (!Number.isSafeInteger(value) || value < 0) {
135
+ throw new Error('DeterministicMath.integerSqrt: value must be a non-negative safe integer');
136
+ }
137
+ if (value < 2)
138
+ return value;
139
+ let low = 1;
140
+ let high = Math.min(value, 94_906_266);
141
+ while (low <= high) {
142
+ const mid = low + Math.trunc((high - low) / 2);
143
+ if (mid <= Math.trunc(value / mid)) {
144
+ low = mid + 1;
145
+ }
146
+ else {
147
+ high = mid - 1;
148
+ }
149
+ }
150
+ return high;
151
+ },
133
152
  abs(value) {
134
153
  assertFixed(value);
135
154
  return saturate(value < 0 ? -value : value, minFixed, maxFixed);
@@ -265,10 +284,14 @@ function createDeterministicMath(fixedScale = 1_000, minFixed = DEFAULT_MIN_FIXE
265
284
  const ay = y < 0 ? -y : y;
266
285
  const swap = ay > ax;
267
286
  const z = this.div(swap ? ax : ay, swap ? ay : ax);
287
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
268
288
  const K1 = this.toFixed(Math.PI / 4);
289
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
269
290
  const K2 = this.toFixed(0.2447);
291
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
270
292
  const K3 = this.toFixed(0.0663);
271
293
  const atanRad = this.add(this.mul(K1, z), this.mul(this.mul(z, this.sub(fixedScale, z)), this.add(K2, this.mul(K3, z))));
294
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
272
295
  let turn = this.mul(atanRad, this.toFixed(1 / (2 * Math.PI)));
273
296
  if (swap) {
274
297
  turn = this.sub(fixedScale / 4, turn);
@@ -288,6 +311,11 @@ function createDeterministicMath(fixedScale = 1_000, minFixed = DEFAULT_MIN_FIXE
288
311
  }
289
312
  return ((full % fixedScale) + fixedScale) % fixedScale;
290
313
  },
314
+ atan2Signed(y, x) {
315
+ const turn = this.atan2(y, x);
316
+ const half = fixedScale / 2;
317
+ return turn >= half ? turn - fixedScale : turn;
318
+ },
291
319
  wrapTurns(t) {
292
320
  assertFixed(t);
293
321
  return ((t % fixedScale) + fixedScale) % fixedScale;
@@ -304,10 +332,12 @@ function createDeterministicMath(fixedScale = 1_000, minFixed = DEFAULT_MIN_FIXE
304
332
  },
305
333
  turnsToRadians(t) {
306
334
  assertFixed(t);
335
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
307
336
  return saturate(Math.trunc((t * this.toFixed(2 * Math.PI)) / fixedScale), minFixed, maxFixed);
308
337
  },
309
338
  radiansToTurns(r) {
310
339
  assertFixed(r);
340
+ // rdm-ignore-next-line determinism/no-float-format: this.toFixed is the deterministic fixed-point conversion helper on this math instance, not Number.prototype.toFixed
311
341
  return saturate(Math.trunc((r * fixedScale) / this.toFixed(2 * Math.PI)), minFixed, maxFixed);
312
342
  },
313
343
  hypot(x, y) {
@@ -35,6 +35,12 @@ const TRANSFER_SENDS_PER_PUMP = 2;
35
35
  function createNetworkedSyncplayClient(options) {
36
36
  const sessionMsgType = options.sessionMsgType ?? DEFAULT_MSG_TYPE;
37
37
  const maxPredictionTicks = options.maxPredictionTicks ?? DEFAULT_MAX_PREDICTION;
38
+ if (options.maxSnapshotBytes !== undefined && (!Number.isInteger(options.maxSnapshotBytes) || options.maxSnapshotBytes < 1)) {
39
+ throw new Error(`createNetworkedSyncplayClient: maxSnapshotBytes must be a positive integer, received ${options.maxSnapshotBytes}`);
40
+ }
41
+ const snapshotCollectorOptions = options.maxSnapshotBytes === undefined
42
+ ? {}
43
+ : { maxSnapshotBytes: options.maxSnapshotBytes };
38
44
  const checksumCadenceTicks = options.checksumCadenceTicks ?? DEFAULT_CHECKSUM_CADENCE_TICKS;
39
45
  const pingIntervalMs = options.pacing?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
40
46
  const targetLeadTicks = options.pacing?.targetLeadTicks ?? DEFAULT_TARGET_LEAD_TICKS;
@@ -83,6 +89,7 @@ function createNetworkedSyncplayClient(options) {
83
89
  const ownInputs = new Map();
84
90
  const confirmed = new Map();
85
91
  let lastConfirmedBySlot = [];
92
+ let confirmedPlayerPresence = [];
86
93
  let confirmationQueue;
87
94
  let secretFailure;
88
95
  let secrets;
@@ -93,7 +100,7 @@ function createNetworkedSyncplayClient(options) {
93
100
  let schemaHash = '';
94
101
  const reportedTicks = [];
95
102
  let lastAutoReportedTick = -1;
96
- let transferCollector = (0, session_snapshot_js_1.createSnapshotTransferCollector)();
103
+ let transferCollector = (0, session_snapshot_js_1.createSnapshotTransferCollector)(snapshotCollectorOptions);
97
104
  const outgoingTransferQueue = [];
98
105
  let donorTransferCounter = 0;
99
106
  let readyResolve;
@@ -121,6 +128,7 @@ function createNetworkedSyncplayClient(options) {
121
128
  seed = message.seed;
122
129
  playerCount = message.playerCount;
123
130
  lastConfirmedBySlot = Array.from({ length: playerCount }, () => null);
131
+ confirmedPlayerPresence = unknownPresence();
124
132
  appliedThrough = -1;
125
133
  predictedThrough = 0;
126
134
  timeline.length = 0;
@@ -128,7 +136,7 @@ function createNetworkedSyncplayClient(options) {
128
136
  confirmed.clear();
129
137
  reportedTicks.length = 0;
130
138
  lastAutoReportedTick = -1;
131
- transferCollector = (0, session_snapshot_js_1.createSnapshotTransferCollector)();
139
+ transferCollector = (0, session_snapshot_js_1.createSnapshotTransferCollector)(snapshotCollectorOptions);
132
140
  outgoingTransferQueue.length = 0;
133
141
  tickRateHz = message.tickRateHz;
134
142
  frameMs = tickRateHz > 0 ? 1000 / tickRateHz : 0;
@@ -445,7 +453,12 @@ function createNetworkedSyncplayClient(options) {
445
453
  function applyConfirmedFrames(frames) {
446
454
  for (const frame of frames) {
447
455
  if (frame.tick > appliedThrough) {
448
- confirmed.set(frame.tick, { inputs: frame.inputs, commands: frame.commands ?? [] });
456
+ confirmed.set(frame.tick, {
457
+ inputs: frame.inputs,
458
+ commands: frame.commands ?? [],
459
+ substitutedSlots: frame.substitutedSlots,
460
+ ...(frame.occupiedSlots === undefined ? {} : { occupiedSlots: frame.occupiedSlots }),
461
+ });
449
462
  }
450
463
  }
451
464
  while (confirmed.has(appliedThrough + 1)) {
@@ -453,7 +466,8 @@ function createNetworkedSyncplayClient(options) {
453
466
  const confFrame = confirmed.get(tick);
454
467
  const confInputs = confFrame.inputs;
455
468
  const predFrame = timeline[tick];
456
- if (predFrame === undefined || (0, canonical_js_1.canonicalStringify)(confFrame) !== (0, canonical_js_1.canonicalStringify)(predFrame)) {
469
+ const simulationFrame = { inputs: confFrame.inputs, commands: confFrame.commands };
470
+ if (predFrame === undefined || (0, canonical_js_1.canonicalStringify)(simulationFrame) !== (0, canonical_js_1.canonicalStringify)(predFrame)) {
457
471
  timeline[tick] = { inputs: confInputs.slice(), commands: confFrame.commands };
458
472
  if (predictedThrough > tick) {
459
473
  // Rollback: we had already predicted this tick and the confirmed inputs differ.
@@ -473,9 +487,24 @@ function createNetworkedSyncplayClient(options) {
473
487
  lastConfirmedBySlot[s] = confInputs[s];
474
488
  }
475
489
  appliedThrough = tick;
490
+ confirmedPlayerPresence = classifyPresence(confFrame);
476
491
  }
477
492
  autoReportCrossedCadences();
478
493
  }
494
+ function unknownPresence() {
495
+ return Object.freeze(Array.from({ length: playerCount }, () => 'unknown'));
496
+ }
497
+ function classifyPresence(frame) {
498
+ if (frame.occupiedSlots === undefined)
499
+ return unknownPresence();
500
+ const occupied = new Set(frame.occupiedSlots);
501
+ const substituted = new Set(frame.substitutedSlots);
502
+ return Object.freeze(Array.from({ length: playerCount }, (_, playerSlot) => {
503
+ if (!occupied.has(playerSlot))
504
+ return 'empty';
505
+ return substituted.has(playerSlot) ? 'substituted' : 'human';
506
+ }));
507
+ }
479
508
  // ── M5.5: cadence-aligned checksum reports (quorum food + desync telemetry) ──
480
509
  function autoReportCrossedCadences() {
481
510
  let next = (Math.floor(lastAutoReportedTick / checksumCadenceTicks) + 1) * checksumCadenceTicks;
@@ -670,6 +699,7 @@ function createNetworkedSyncplayClient(options) {
670
699
  });
671
700
  }
672
701
  appliedThrough = snapshot.tick;
702
+ confirmedPlayerPresence = unknownPresence();
673
703
  predictedThrough = snapshot.tick + 1;
674
704
  // Never send input for ticks at/below the hydration point — they are
675
705
  // already confirmed. Anything the cursor had scheduled beyond it stands.
@@ -709,6 +739,9 @@ function createNetworkedSyncplayClient(options) {
709
739
  get rollbacks() {
710
740
  return rollbacks;
711
741
  },
742
+ get confirmedPlayerPresence() {
743
+ return confirmedPlayerPresence;
744
+ },
712
745
  get netStats() {
713
746
  return {
714
747
  rttMs: rttMs < 0 ? -1 : Math.round(rttMs),
@@ -0,0 +1,279 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDeterministicPhysics3DDestructionCert = runDeterministicPhysics3DDestructionCert;
4
+ const canonical_js_1 = require("./canonical.js");
5
+ const physics3d_js_1 = require("./physics3d.js");
6
+ const physics3d_shared_js_1 = require("./physics3d-shared.js");
7
+ const physics3d_voxel_js_1 = require("./physics3d-voxel.js");
8
+ const CELL = 1;
9
+ /**
10
+ * Deterministically carved chunk: a solid cube cooks to a single box, which
11
+ * under-represents real destruction debris. Dropping every cell where
12
+ * (x + 2y + 3z) % 5 === 0 forces multi-child merged compounds while keeping
13
+ * ~80% of the mass.
14
+ */
15
+ function carvedChunk(side) {
16
+ const occupancy = Array(side * side * side).fill(1);
17
+ for (let z = 0; z < side; z += 1) {
18
+ for (let y = 0; y < side; y += 1) {
19
+ for (let x = 0; x < side; x += 1) {
20
+ if ((x + 2 * y + 3 * z) % 5 === 0) {
21
+ occupancy[x + side * (y + side * z)] = 0;
22
+ }
23
+ }
24
+ }
25
+ }
26
+ if (!occupancy.includes(1)) {
27
+ throw new Error('destruction cert: carved chunk lost every voxel');
28
+ }
29
+ return { dimX: side, dimY: side, dimZ: side, cellSize: CELL, occupancy };
30
+ }
31
+ function chunkBody(id, x, y, z, chunk, mass) {
32
+ return {
33
+ id, kind: 'dynamic', x, y, z, vx: 0, vy: 0, vz: 0,
34
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
35
+ angularVel: { x: 0, y: 0, z: 0 },
36
+ mass,
37
+ shape: (0, physics3d_voxel_js_1.cookDeterministicVoxelChunkCollider3D)(chunk),
38
+ layer: 1, mask: 0xffff,
39
+ };
40
+ }
41
+ /**
42
+ * Split occupancy along the largest axis of the OCCUPIED bounding box, cutting
43
+ * at that box's midpoint so both halves are guaranteed at least one voxel
44
+ * (carved chunks can have fully-empty grid halves — cutting the raw grid would
45
+ * cook an empty compound and throw). Returns null when the occupied box is a
46
+ * single cell on every axis (chip instead of split).
47
+ */
48
+ function splitChunk(chunk) {
49
+ let minX = Infinity;
50
+ let minY = Infinity;
51
+ let minZ = Infinity;
52
+ let maxX = -Infinity;
53
+ let maxY = -Infinity;
54
+ let maxZ = -Infinity;
55
+ for (let z = 0; z < chunk.dimZ; z += 1) {
56
+ for (let y = 0; y < chunk.dimY; y += 1) {
57
+ for (let x = 0; x < chunk.dimX; x += 1) {
58
+ if (chunk.occupancy[x + chunk.dimX * (y + chunk.dimY * z)] !== 0) {
59
+ minX = Math.min(minX, x);
60
+ maxX = Math.max(maxX, x);
61
+ minY = Math.min(minY, y);
62
+ maxY = Math.max(maxY, y);
63
+ minZ = Math.min(minZ, z);
64
+ maxZ = Math.max(maxZ, z);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ if (minX === Infinity) {
70
+ throw new Error('destruction cert: cannot split an empty chunk');
71
+ }
72
+ const extents = { x: maxX - minX + 1, y: maxY - minY + 1, z: maxZ - minZ + 1 };
73
+ const axis = extents.x >= extents.y && extents.x >= extents.z ? 'x' : extents.y >= extents.z ? 'y' : 'z';
74
+ if (extents[axis] < 2) {
75
+ return null;
76
+ }
77
+ const start = axis === 'x' ? minX : axis === 'y' ? minY : minZ;
78
+ // Occupied cells exist at both bbox extremes along `axis`, and
79
+ // start < cut <= start + extent - 1, so both slices keep >= 1 voxel.
80
+ const cut = start + Math.floor(extents[axis] / 2);
81
+ const dims = { x: chunk.dimX, y: chunk.dimY, z: chunk.dimZ };
82
+ const slice = (from, to) => {
83
+ const dimX = axis === 'x' ? to - from : chunk.dimX;
84
+ const dimY = axis === 'y' ? to - from : chunk.dimY;
85
+ const dimZ = axis === 'z' ? to - from : chunk.dimZ;
86
+ const occupancy = Array(dimX * dimY * dimZ).fill(0);
87
+ for (let z = 0; z < dimZ; z += 1) {
88
+ for (let y = 0; y < dimY; y += 1) {
89
+ for (let x = 0; x < dimX; x += 1) {
90
+ const sx = axis === 'x' ? x + from : x;
91
+ const sy = axis === 'y' ? y + from : y;
92
+ const sz = axis === 'z' ? z + from : z;
93
+ occupancy[x + dimX * (y + dimY * z)] = chunk.occupancy[sx + chunk.dimX * (sy + chunk.dimY * sz)];
94
+ }
95
+ }
96
+ }
97
+ return { dimX, dimY, dimZ, cellSize: chunk.cellSize, occupancy };
98
+ };
99
+ return { lower: slice(0, cut), upper: slice(cut, dims[axis]), axis, offset: cut * chunk.cellSize };
100
+ }
101
+ function occupiedCount(chunk) {
102
+ return chunk.occupancy.reduce((sum, cell) => sum + (cell !== 0 ? 1 : 0), 0);
103
+ }
104
+ function runDeterministicPhysics3DDestructionCert(options = {}) {
105
+ const chunks = options.chunks ?? 64;
106
+ const side = options.chunkSide ?? 4;
107
+ const piles = options.piles ?? 8;
108
+ const warmupFrames = options.warmupFrames ?? 300;
109
+ const measuredFrames = options.measuredFrames ?? 1_800;
110
+ const fractureEveryFrames = options.fractureEveryFrames ?? 30;
111
+ // Derived from measuredFrames (the 1800-frame default still lands on 900), so a
112
+ // caller that only lowers measuredFrames does not trip the [0, measuredFrames)
113
+ // validation below.
114
+ const snapshotAtMeasuredFrame = options.snapshotAtMeasuredFrame ?? Math.floor(measuredFrames / 2);
115
+ const tickRateHz = options.tickRateHz ?? 30;
116
+ const now = options.now ?? (() => performance.now());
117
+ for (const [name, value] of [
118
+ ['chunks', chunks], ['chunkSide', side], ['piles', piles], ['measuredFrames', measuredFrames],
119
+ ['fractureEveryFrames', fractureEveryFrames], ['tickRateHz', tickRateHz],
120
+ ]) {
121
+ if (!Number.isInteger(value) || value < 1) {
122
+ throw new Error(`runDeterministicPhysics3DDestructionCert: ${name} must be a positive integer, received ${value}`);
123
+ }
124
+ }
125
+ // The carve zeroes every cell where (x + 2y + 3z) % 5 === 0, which for side 1
126
+ // is the chunk's only voxel — reject it here rather than failing later inside
127
+ // the carve with an error that does not name the offending option.
128
+ if (side < 2) {
129
+ throw new Error(`runDeterministicPhysics3DDestructionCert: chunkSide must be at least 2 (the carve pattern empties a 1x1x1 chunk), received ${side}`);
130
+ }
131
+ if (!Number.isInteger(warmupFrames) || warmupFrames < 0) {
132
+ throw new Error(`runDeterministicPhysics3DDestructionCert: warmupFrames must be a non-negative integer, received ${warmupFrames}`);
133
+ }
134
+ if (!Number.isInteger(snapshotAtMeasuredFrame) || snapshotAtMeasuredFrame < 0 || snapshotAtMeasuredFrame >= measuredFrames) {
135
+ throw new Error(`runDeterministicPhysics3DDestructionCert: snapshotAtMeasuredFrame must be an integer in [0, measuredFrames), received ${snapshotAtMeasuredFrame}`);
136
+ }
137
+ const gravityY = -9.8 / (tickRateHz * tickRateHz);
138
+ const build = (reversed) => {
139
+ const meta = new Map();
140
+ const bodies = [{
141
+ id: 'floor', kind: 'static', x: 0, y: -1, z: 0, vx: 0, vy: 0, vz: 0,
142
+ shape: { type: 'box', halfX: 400, halfY: 1, halfZ: 400 }, layer: 1, mask: 0xffff,
143
+ }];
144
+ const chunkSpan = side * CELL;
145
+ for (let index = 0; index < chunks; index += 1) {
146
+ const pile = index % piles;
147
+ const level = Math.floor(index / piles);
148
+ const chunk = carvedChunk(side);
149
+ const id = `c${String(index).padStart(3, '0')}`;
150
+ meta.set(id, { chunk });
151
+ bodies.push(chunkBody(id, (pile % 4) * (chunkSpan + 1) + (level % 2) * 0.4, 0.5 + level * (chunkSpan + 0.2), Math.floor(pile / 4) * (chunkSpan + 1) + (level % 3) * 0.3, chunk, occupiedCount(chunk)));
152
+ }
153
+ const authored = reversed ? [...bodies].reverse() : bodies;
154
+ return { world: (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(authored), meta, fractures: 0, peakBodyCount: authored.length, peakContactPairs: 0, peakSleepingCount: 0 };
155
+ };
156
+ const fracture = (state, reversed) => {
157
+ // Explicit comparator: this ordering picks the fracture target, so it feeds
158
+ // straight into the run checksum — never leave it to the default sort.
159
+ const alive = state.world.bodies
160
+ .filter((entry) => entry.kind === 'dynamic' && state.meta.has(entry.id))
161
+ .map((entry) => entry.id)
162
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
163
+ if (alive.length === 0) {
164
+ return;
165
+ }
166
+ const targetId = alive[state.fractures % alive.length];
167
+ const target = state.world.bodies.find((entry) => entry.id === targetId);
168
+ if (target === undefined) {
169
+ throw new Error(`destruction cert: fracture target ${targetId} missing`);
170
+ }
171
+ const split = splitChunk(state.meta.get(targetId).chunk);
172
+ state.fractures += 1;
173
+ if (split === null) {
174
+ // single-voxel chunk: chip it out of the simulation entirely
175
+ state.meta.delete(targetId);
176
+ state.world = (0, physics3d_js_1.applyDeterministicWorldEdit3D)(state.world, { remove: [targetId] });
177
+ return;
178
+ }
179
+ const axisVector = { x: split.axis === 'x' ? split.offset : 0, y: split.axis === 'y' ? split.offset : 0, z: split.axis === 'z' ? split.offset : 0 };
180
+ const shifted = (0, physics3d_shared_js_1.rotatePointByQuat)(target.orientation ?? { x: 0, y: 0, z: 0, w: 1 }, axisVector);
181
+ const kick = 0.02;
182
+ const lowerBody = {
183
+ ...chunkBody(`${targetId}a`, target.x, target.y, target.z, split.lower, occupiedCount(split.lower)),
184
+ vx: target.vx - (split.axis === 'x' ? kick : 0), vy: target.vy, vz: target.vz - (split.axis === 'z' ? kick : 0),
185
+ orientation: target.orientation, angularVel: target.angularVel,
186
+ };
187
+ const upperBody = {
188
+ ...chunkBody(`${targetId}b`, target.x + shifted.x, target.y + shifted.y, target.z + shifted.z, split.upper, occupiedCount(split.upper)),
189
+ vx: target.vx + (split.axis === 'x' ? kick : 0), vy: target.vy, vz: target.vz + (split.axis === 'z' ? kick : 0),
190
+ orientation: target.orientation, angularVel: target.angularVel,
191
+ };
192
+ state.meta.delete(targetId);
193
+ state.meta.set(lowerBody.id, { chunk: split.lower });
194
+ state.meta.set(upperBody.id, { chunk: split.upper });
195
+ const added = reversed ? [upperBody, lowerBody] : [lowerBody, upperBody];
196
+ state.world = (0, physics3d_js_1.applyDeterministicWorldEdit3D)(state.world, { remove: [targetId], add: added });
197
+ };
198
+ const stepOnce = (state) => {
199
+ state.world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(state.world, { gravityY, computeChecksum: false }).world;
200
+ state.peakBodyCount = Math.max(state.peakBodyCount, state.world.bodies.length);
201
+ state.peakContactPairs = Math.max(state.peakContactPairs, state.world.activePairs.length);
202
+ state.peakSleepingCount = Math.max(state.peakSleepingCount, state.world.bodies.filter((entry) => entry.sleeping === true).length);
203
+ };
204
+ const runMeasured = (state, reversed, timings) => {
205
+ let snapshot = state.world;
206
+ let snapshotMeta = new Map(state.meta);
207
+ let snapshotFractures = state.fractures;
208
+ for (let frame = 0; frame < measuredFrames; frame += 1) {
209
+ if (frame === snapshotAtMeasuredFrame) {
210
+ snapshot = (0, canonical_js_1.cloneCanonical)(state.world);
211
+ snapshotMeta = new Map([...state.meta.entries()].map(([id, entry]) => [id, { chunk: (0, canonical_js_1.cloneCanonical)(entry.chunk) }]));
212
+ snapshotFractures = state.fractures;
213
+ }
214
+ // Timed window covers the whole frame — fracture (world edit + re-cooking)
215
+ // is a cost the game pays inside its tick, not free.
216
+ const startedAt = timings === undefined ? 0 : now();
217
+ if (frame % fractureEveryFrames === 0) {
218
+ fracture(state, reversed);
219
+ }
220
+ stepOnce(state);
221
+ if (timings !== undefined) {
222
+ timings.push(now() - startedAt);
223
+ }
224
+ }
225
+ return { snapshot, snapshotMeta, snapshotFractures };
226
+ };
227
+ const initialChunkColliderChildren = (0, physics3d_voxel_js_1.cookDeterministicVoxelChunkCollider3D)(carvedChunk(side)).children.length;
228
+ // primary run (timed)
229
+ const primary = build(false);
230
+ for (let frame = 0; frame < warmupFrames; frame += 1) {
231
+ stepOnce(primary);
232
+ }
233
+ const timings = [];
234
+ const { snapshot, snapshotMeta, snapshotFractures } = runMeasured(primary, false, timings);
235
+ const finalChecksum = (0, canonical_js_1.defaultChecksum)(primary.world);
236
+ const snapshotBytes = new TextEncoder().encode((0, canonical_js_1.canonicalStringify)(snapshot)).length;
237
+ // restore + resimulate from the snapshot with the same schedule
238
+ const restored = { world: (0, canonical_js_1.cloneCanonical)(snapshot), meta: snapshotMeta, fractures: snapshotFractures, peakBodyCount: 0, peakContactPairs: 0, peakSleepingCount: 0 };
239
+ for (let frame = snapshotAtMeasuredFrame; frame < measuredFrames; frame += 1) {
240
+ if (frame % fractureEveryFrames === 0) {
241
+ fracture(restored, false);
242
+ }
243
+ stepOnce(restored);
244
+ }
245
+ const restoredChecksum = (0, canonical_js_1.defaultChecksum)(restored.world);
246
+ // reversed-authoring-order rerun (untimed)
247
+ const reversedRun = build(true);
248
+ for (let frame = 0; frame < warmupFrames; frame += 1) {
249
+ stepOnce(reversedRun);
250
+ }
251
+ runMeasured(reversedRun, true, undefined);
252
+ const reversedOrderChecksum = (0, canonical_js_1.defaultChecksum)(reversedRun.world);
253
+ const sorted = [...timings].sort((left, right) => left - right);
254
+ const pick = (rank) => sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor((rank / 100) * sorted.length))];
255
+ const rollbackEquivalent = finalChecksum === restoredChecksum;
256
+ const stableUnderAuthoringOrderPermutation = finalChecksum === reversedOrderChecksum;
257
+ return {
258
+ result: rollbackEquivalent && stableUnderAuthoringOrderPermutation ? 'pass' : 'fail',
259
+ finalChecksum,
260
+ restoredChecksum,
261
+ rollbackEquivalent,
262
+ reversedOrderChecksum,
263
+ stableUnderAuthoringOrderPermutation,
264
+ p50StepMs: pick(50),
265
+ p95StepMs: pick(95),
266
+ p99StepMs: pick(99),
267
+ maxStepMs: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
268
+ measuredFrames,
269
+ fractures: primary.fractures,
270
+ initialChunkColliderChildren,
271
+ finalBodyCount: primary.world.bodies.length,
272
+ peakBodyCount: primary.peakBodyCount,
273
+ finalSleepingCount: primary.world.bodies.filter((entry) => entry.sleeping === true).length,
274
+ peakSleepingCount: primary.peakSleepingCount,
275
+ finalContactPairs: primary.world.activePairs.length,
276
+ peakContactPairs: primary.peakContactPairs,
277
+ snapshotBytes,
278
+ };
279
+ }