@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.
- package/README.md +11 -4
- package/dist/authority-room.d.ts +6 -0
- package/dist/authority-room.js +5 -1
- package/dist/browser.d.ts +7 -3
- package/dist/browser.js +3 -1
- package/dist/cjs/authority-room.js +5 -1
- package/dist/cjs/browser.js +9 -2
- package/dist/cjs/cosmetic-physics3d.js +201 -0
- package/dist/cjs/creator.js +26 -1
- package/dist/cjs/engine-complete.js +9 -4
- package/dist/cjs/engine-parity.js +5 -2
- package/dist/cjs/index.js +12 -2
- package/dist/cjs/input-authority.js +33 -1
- package/dist/cjs/math.js +30 -0
- package/dist/cjs/networked-client.js +37 -4
- package/dist/cjs/physics3d-destruction-cert.js +279 -0
- package/dist/cjs/physics3d-solver.js +95 -32
- package/dist/cjs/physics3d-voxel.js +94 -0
- package/dist/cjs/physics3d.js +121 -3
- package/dist/cjs/sdk-session.js +1 -0
- package/dist/cjs/session-wire.js +12 -6
- package/dist/cli.js +47 -3
- package/dist/cosmetic-physics3d.d.ts +41 -0
- package/dist/cosmetic-physics3d.js +196 -0
- package/dist/creator.d.ts +8 -0
- package/dist/creator.js +7 -0
- package/dist/engine-complete.js +9 -4
- package/dist/engine-parity.js +5 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/input-authority.d.ts +3 -0
- package/dist/input-authority.js +33 -1
- package/dist/math.d.ts +4 -0
- package/dist/math.js +30 -0
- package/dist/networked-client.d.ts +9 -0
- package/dist/networked-client.js +37 -4
- package/dist/physics3d-destruction-cert.d.ts +35 -0
- package/dist/physics3d-destruction-cert.js +276 -0
- package/dist/physics3d-solver.d.ts +26 -1
- package/dist/physics3d-solver.js +94 -32
- package/dist/physics3d-voxel.d.ts +20 -0
- package/dist/physics3d-voxel.js +91 -0
- package/dist/physics3d.d.ts +30 -2
- package/dist/physics3d.js +120 -3
- package/dist/sdk-session.d.ts +2 -0
- package/dist/sdk-session.js +1 -0
- package/dist/session-wire.js +12 -6
- 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)(
|
|
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, {
|
|
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
|
-
|
|
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
|
+
}
|