@series-inc/rundot-syncplay 5.23.0-beta.7
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 +610 -0
- package/dist/animation.d.ts +119 -0
- package/dist/animation.js +488 -0
- package/dist/asset-cooking.d.ts +82 -0
- package/dist/asset-cooking.js +364 -0
- package/dist/authority-room.d.ts +217 -0
- package/dist/authority-room.js +701 -0
- package/dist/bot-documents.d.ts +115 -0
- package/dist/bot-documents.js +549 -0
- package/dist/bot-primitives.d.ts +152 -0
- package/dist/bot-primitives.js +472 -0
- package/dist/browser.d.ts +57 -0
- package/dist/browser.js +53 -0
- package/dist/canonical.d.ts +12 -0
- package/dist/canonical.js +288 -0
- package/dist/certification.d.ts +455 -0
- package/dist/certification.js +1855 -0
- package/dist/chaos-certification.d.ts +56 -0
- package/dist/chaos-certification.js +1397 -0
- package/dist/cjs/animation.js +495 -0
- package/dist/cjs/asset-cooking.js +372 -0
- package/dist/cjs/authority-room.js +704 -0
- package/dist/cjs/bot-documents.js +556 -0
- package/dist/cjs/bot-primitives.js +482 -0
- package/dist/cjs/browser.js +96 -0
- package/dist/cjs/canonical.js +299 -0
- package/dist/cjs/certification.js +1860 -0
- package/dist/cjs/chaos-certification.js +1402 -0
- package/dist/cjs/collections.js +36 -0
- package/dist/cjs/collision.js +96 -0
- package/dist/cjs/commands.js +987 -0
- package/dist/cjs/config-bundle.js +491 -0
- package/dist/cjs/creator.js +30 -0
- package/dist/cjs/deterministic-primitives.js +134 -0
- package/dist/cjs/deterministic-session.js +1410 -0
- package/dist/cjs/ecs-lite.js +155 -0
- package/dist/cjs/effects-adapter.js +97 -0
- package/dist/cjs/engine-complete.js +2297 -0
- package/dist/cjs/engine-core-proof.js +70 -0
- package/dist/cjs/engine-parity-matrix.js +874 -0
- package/dist/cjs/engine-parity.js +3183 -0
- package/dist/cjs/engine-runtime.js +3141 -0
- package/dist/cjs/errors.js +22 -0
- package/dist/cjs/events.js +247 -0
- package/dist/cjs/examples/counter-duel.js +157 -0
- package/dist/cjs/examples/fixed-arena.js +368 -0
- package/dist/cjs/index.js +229 -0
- package/dist/cjs/input-authority.js +150 -0
- package/dist/cjs/input-button.js +256 -0
- package/dist/cjs/input-timeline.js +418 -0
- package/dist/cjs/instant-replay.js +66 -0
- package/dist/cjs/lag-compensation3d.js +837 -0
- package/dist/cjs/local-authority-server-runner.js +1121 -0
- package/dist/cjs/local-authority-wire.js +1497 -0
- package/dist/cjs/local-authority.js +817 -0
- package/dist/cjs/local-ws-adapter.js +150 -0
- package/dist/cjs/match-log.js +61 -0
- package/dist/cjs/math.js +157 -0
- package/dist/cjs/mock-network-adapter.js +26 -0
- package/dist/cjs/movement.js +703 -0
- package/dist/cjs/movement3d.js +1246 -0
- package/dist/cjs/multiclient-authority.js +308 -0
- package/dist/cjs/multiplayer-service.js +434 -0
- package/dist/cjs/navigation.js +347 -0
- package/dist/cjs/navmesh.js +1437 -0
- package/dist/cjs/networked-client.js +691 -0
- package/dist/cjs/networked-input-authority.js +443 -0
- package/dist/cjs/node.js +47 -0
- package/dist/cjs/number-lab.js +78 -0
- package/dist/cjs/offline-session.js +370 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pause-resume.js +43 -0
- package/dist/cjs/physics-cert.js +426 -0
- package/dist/cjs/physics2d.js +751 -0
- package/dist/cjs/physics3d.js +1996 -0
- package/dist/cjs/prediction-culling.js +203 -0
- package/dist/cjs/protocol-lab.js +1299 -0
- package/dist/cjs/r3f-render-adapter.js +209 -0
- package/dist/cjs/random.js +59 -0
- package/dist/cjs/replay-bundle.js +1598 -0
- package/dist/cjs/replay-inspect.js +236 -0
- package/dist/cjs/replay-scoring.js +68 -0
- package/dist/cjs/replay.js +356 -0
- package/dist/cjs/reports.js +181 -0
- package/dist/cjs/rollback-history.js +251 -0
- package/dist/cjs/runtime-guards.js +111 -0
- package/dist/cjs/sample-lab.js +145 -0
- package/dist/cjs/sample-scenes.js +752 -0
- package/dist/cjs/schema-artifacts.js +359 -0
- package/dist/cjs/schema-codegen.js +544 -0
- package/dist/cjs/schema-dsl-binary.js +655 -0
- package/dist/cjs/schema.js +319 -0
- package/dist/cjs/sdk-offline.js +99 -0
- package/dist/cjs/session-promotion.js +74 -0
- package/dist/cjs/session-snapshot.js +265 -0
- package/dist/cjs/session-wire.js +216 -0
- package/dist/cjs/signals.js +103 -0
- package/dist/cjs/sparse-set.js +169 -0
- package/dist/cjs/synctest.js +136 -0
- package/dist/cjs/system-lifecycle.js +286 -0
- package/dist/cjs/timers.js +61 -0
- package/dist/cjs/tools/static-checker.js +1468 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/venus-mp-budget.js +125 -0
- package/dist/cjs/ws-frame.js +255 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11379 -0
- package/dist/collections.d.ts +9 -0
- package/dist/collections.js +30 -0
- package/dist/collision.d.ts +25 -0
- package/dist/collision.js +85 -0
- package/dist/commands.d.ts +137 -0
- package/dist/commands.js +977 -0
- package/dist/config-bundle.d.ts +89 -0
- package/dist/config-bundle.js +483 -0
- package/dist/creator.d.ts +20 -0
- package/dist/creator.js +11 -0
- package/dist/deterministic-primitives.d.ts +59 -0
- package/dist/deterministic-primitives.js +126 -0
- package/dist/deterministic-session.d.ts +197 -0
- package/dist/deterministic-session.js +1401 -0
- package/dist/ecs-lite.d.ts +26 -0
- package/dist/ecs-lite.js +152 -0
- package/dist/effects-adapter.d.ts +21 -0
- package/dist/effects-adapter.js +94 -0
- package/dist/engine-complete.d.ts +45 -0
- package/dist/engine-complete.js +2292 -0
- package/dist/engine-core-proof.d.ts +26 -0
- package/dist/engine-core-proof.js +66 -0
- package/dist/engine-parity-matrix.d.ts +300 -0
- package/dist/engine-parity-matrix.js +869 -0
- package/dist/engine-parity.d.ts +74 -0
- package/dist/engine-parity.js +3178 -0
- package/dist/engine-runtime.d.ts +351 -0
- package/dist/engine-runtime.js +3123 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/events.d.ts +45 -0
- package/dist/events.js +242 -0
- package/dist/examples/counter-duel.d.ts +43 -0
- package/dist/examples/counter-duel.js +150 -0
- package/dist/examples/fixed-arena.d.ts +110 -0
- package/dist/examples/fixed-arena.js +356 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +66 -0
- package/dist/input-authority.d.ts +82 -0
- package/dist/input-authority.js +147 -0
- package/dist/input-button.d.ts +36 -0
- package/dist/input-button.js +249 -0
- package/dist/input-timeline.d.ts +65 -0
- package/dist/input-timeline.js +412 -0
- package/dist/instant-replay.d.ts +59 -0
- package/dist/instant-replay.js +62 -0
- package/dist/lag-compensation3d.d.ts +146 -0
- package/dist/lag-compensation3d.js +827 -0
- package/dist/local-authority-server-runner.d.ts +79 -0
- package/dist/local-authority-server-runner.js +1118 -0
- package/dist/local-authority-wire.d.ts +142 -0
- package/dist/local-authority-wire.js +1477 -0
- package/dist/local-authority.d.ts +160 -0
- package/dist/local-authority.js +810 -0
- package/dist/local-ws-adapter.d.ts +14 -0
- package/dist/local-ws-adapter.js +146 -0
- package/dist/match-log.d.ts +74 -0
- package/dist/match-log.js +57 -0
- package/dist/math.d.ts +41 -0
- package/dist/math.js +154 -0
- package/dist/mock-network-adapter.d.ts +13 -0
- package/dist/mock-network-adapter.js +23 -0
- package/dist/movement.d.ts +155 -0
- package/dist/movement.js +694 -0
- package/dist/movement3d.d.ts +195 -0
- package/dist/movement3d.js +1238 -0
- package/dist/multiclient-authority.d.ts +30 -0
- package/dist/multiclient-authority.js +305 -0
- package/dist/multiplayer-service.d.ts +153 -0
- package/dist/multiplayer-service.js +429 -0
- package/dist/navigation.d.ts +91 -0
- package/dist/navigation.js +338 -0
- package/dist/navmesh.d.ts +236 -0
- package/dist/navmesh.js +1423 -0
- package/dist/networked-client.d.ts +124 -0
- package/dist/networked-client.js +688 -0
- package/dist/networked-input-authority.d.ts +51 -0
- package/dist/networked-input-authority.js +439 -0
- package/dist/node.d.ts +22 -0
- package/dist/node.js +19 -0
- package/dist/number-lab.d.ts +15 -0
- package/dist/number-lab.js +75 -0
- package/dist/offline-session.d.ts +2 -0
- package/dist/offline-session.js +367 -0
- package/dist/pause-resume.d.ts +48 -0
- package/dist/pause-resume.js +40 -0
- package/dist/physics-cert.d.ts +40 -0
- package/dist/physics-cert.js +389 -0
- package/dist/physics2d.d.ts +142 -0
- package/dist/physics2d.js +741 -0
- package/dist/physics3d.d.ts +285 -0
- package/dist/physics3d.js +1979 -0
- package/dist/prediction-culling.d.ts +34 -0
- package/dist/prediction-culling.js +200 -0
- package/dist/protocol-lab.d.ts +115 -0
- package/dist/protocol-lab.js +1295 -0
- package/dist/r3f-render-adapter.d.ts +61 -0
- package/dist/r3f-render-adapter.js +203 -0
- package/dist/random.d.ts +13 -0
- package/dist/random.js +55 -0
- package/dist/replay-bundle.d.ts +150 -0
- package/dist/replay-bundle.js +1586 -0
- package/dist/replay-inspect.d.ts +60 -0
- package/dist/replay-inspect.js +228 -0
- package/dist/replay-scoring.d.ts +28 -0
- package/dist/replay-scoring.js +65 -0
- package/dist/replay.d.ts +17 -0
- package/dist/replay.js +351 -0
- package/dist/reports.d.ts +176 -0
- package/dist/reports.js +174 -0
- package/dist/rollback-history.d.ts +46 -0
- package/dist/rollback-history.js +247 -0
- package/dist/runtime-guards.d.ts +1 -0
- package/dist/runtime-guards.js +108 -0
- package/dist/sample-lab.d.ts +32 -0
- package/dist/sample-lab.js +134 -0
- package/dist/sample-scenes.d.ts +86 -0
- package/dist/sample-scenes.js +745 -0
- package/dist/schema-artifacts.d.ts +49 -0
- package/dist/schema-artifacts.js +356 -0
- package/dist/schema-codegen.d.ts +21 -0
- package/dist/schema-codegen.js +538 -0
- package/dist/schema-dsl-binary.d.ts +75 -0
- package/dist/schema-dsl-binary.js +648 -0
- package/dist/schema.d.ts +87 -0
- package/dist/schema.js +315 -0
- package/dist/sdk-offline.d.ts +34 -0
- package/dist/sdk-offline.js +96 -0
- package/dist/session-promotion.d.ts +26 -0
- package/dist/session-promotion.js +70 -0
- package/dist/session-snapshot.d.ts +95 -0
- package/dist/session-snapshot.js +255 -0
- package/dist/session-wire.d.ts +89 -0
- package/dist/session-wire.js +211 -0
- package/dist/signals.d.ts +31 -0
- package/dist/signals.js +99 -0
- package/dist/sparse-set.d.ts +34 -0
- package/dist/sparse-set.js +165 -0
- package/dist/synctest.d.ts +31 -0
- package/dist/synctest.js +133 -0
- package/dist/system-lifecycle.d.ts +72 -0
- package/dist/system-lifecycle.js +281 -0
- package/dist/timers.d.ts +17 -0
- package/dist/timers.js +53 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/static-checker.d.ts +31 -0
- package/dist/tools/static-checker.js +1462 -0
- package/dist/tools/vite-plugin.d.ts +69 -0
- package/dist/tools/vite-plugin.js +203 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.js +1 -0
- package/dist/venus-mp-budget.d.ts +29 -0
- package/dist/venus-mp-budget.js +122 -0
- package/dist/ws-frame.d.ts +49 -0
- package/dist/ws-frame.js +247 -0
- package/package.json +169 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import { canonicalStringify, cloneCanonical, defaultChecksum } from './canonical.js';
|
|
2
|
+
import { DeterministicRandom } from './random.js';
|
|
3
|
+
export function createDeterministicCommandTimeline(config) {
|
|
4
|
+
validateCommandTimelineConfig(config);
|
|
5
|
+
const channels = new Map(config.channels.map((channel) => [channel.id, channel]));
|
|
6
|
+
const validAssetRefs = new Set(config.validAssetRefs ?? []);
|
|
7
|
+
const accepted = [];
|
|
8
|
+
const rejected = [];
|
|
9
|
+
const commandIds = new Set();
|
|
10
|
+
let currentFrame = config.currentFrame;
|
|
11
|
+
const recordTrusted = (command) => {
|
|
12
|
+
const rejection = validateCommand({ ...config, currentFrame }, channels, validAssetRefs, commandIds, command);
|
|
13
|
+
if (rejection !== undefined) {
|
|
14
|
+
rejected.push(rejection);
|
|
15
|
+
return rejection;
|
|
16
|
+
}
|
|
17
|
+
commandIds.add(command.id);
|
|
18
|
+
accepted.push(cloneCanonical({ ...command, submittedFrame: currentFrame }));
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
get currentFrame() {
|
|
23
|
+
return currentFrame;
|
|
24
|
+
},
|
|
25
|
+
get accepted() {
|
|
26
|
+
return sortCommands(accepted).map((command) => cloneCanonical(command));
|
|
27
|
+
},
|
|
28
|
+
get rejected() {
|
|
29
|
+
return rejected.map((command) => cloneCanonical(command));
|
|
30
|
+
},
|
|
31
|
+
setCurrentFrame(frame) {
|
|
32
|
+
if (!Number.isInteger(frame) || frame < currentFrame) {
|
|
33
|
+
throw new Error(`Command timeline frame must advance monotonically from ${currentFrame} to ${frame}`);
|
|
34
|
+
}
|
|
35
|
+
currentFrame = frame;
|
|
36
|
+
},
|
|
37
|
+
record(command) {
|
|
38
|
+
return recordTrusted(command);
|
|
39
|
+
},
|
|
40
|
+
recordFromSlot(senderSlot, command) {
|
|
41
|
+
if (!Number.isInteger(senderSlot) || senderSlot !== command.slot) {
|
|
42
|
+
const rejection = reject(command, 'unauthorized-slot');
|
|
43
|
+
rejected.push(rejection);
|
|
44
|
+
return rejection;
|
|
45
|
+
}
|
|
46
|
+
return recordTrusted(command);
|
|
47
|
+
},
|
|
48
|
+
poll(frame, slot, channelId) {
|
|
49
|
+
return accepted
|
|
50
|
+
.filter((command) => command.frame === frame)
|
|
51
|
+
.filter((command) => slot === undefined || command.slot === slot)
|
|
52
|
+
.filter((command) => channelId === undefined || command.channelId === channelId)
|
|
53
|
+
.sort(compareCommands)
|
|
54
|
+
.map((command) => cloneCanonical(command));
|
|
55
|
+
},
|
|
56
|
+
snapshot() {
|
|
57
|
+
return createDeterministicCommandTimelineSnapshot({
|
|
58
|
+
currentFrame,
|
|
59
|
+
accepted,
|
|
60
|
+
rejected,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
restore(snapshot) {
|
|
64
|
+
validateCommandTimelineSnapshot(snapshot);
|
|
65
|
+
const restoredAccepted = validateRestoredCommands(config, channels, validAssetRefs, snapshot);
|
|
66
|
+
const restoredRejected = snapshot.rejected.map((command) => cloneCanonical(command));
|
|
67
|
+
currentFrame = snapshot.currentFrame;
|
|
68
|
+
accepted.splice(0, accepted.length, ...restoredAccepted);
|
|
69
|
+
rejected.splice(0, rejected.length, ...restoredRejected);
|
|
70
|
+
commandIds.clear();
|
|
71
|
+
for (const command of accepted) {
|
|
72
|
+
commandIds.add(command.id);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
checksum() {
|
|
76
|
+
return defaultChecksum({ accepted: sortCommands(accepted), rejected });
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function runDeterministicCommandTimelineMigrationFixture(players) {
|
|
81
|
+
if (!Number.isInteger(players) || players < 2 || players > 128) {
|
|
82
|
+
throw new Error('command timeline migration players must be between 2 and 128');
|
|
83
|
+
}
|
|
84
|
+
const channels = [
|
|
85
|
+
{
|
|
86
|
+
id: 'ability',
|
|
87
|
+
reliable: true,
|
|
88
|
+
allowPrediction: true,
|
|
89
|
+
maxPayloadBytes: 160,
|
|
90
|
+
maxFrameLead: 8,
|
|
91
|
+
fields: [
|
|
92
|
+
{ name: 'assetRef', kind: 'asset-ref' },
|
|
93
|
+
{ name: 'power', kind: 'integer', min: 0, max: 9 },
|
|
94
|
+
{ name: 'targets', kind: 'player-slot[]', minItems: 1, maxItems: 4, min: 0, max: players - 1 },
|
|
95
|
+
{ name: 'loadout', kind: 'object', fields: [
|
|
96
|
+
{ name: 'assetRef', kind: 'asset-ref' },
|
|
97
|
+
{ name: 'modifiers', kind: 'integer[]', minItems: 1, maxItems: 3, min: 0, max: 9 },
|
|
98
|
+
] },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const config = {
|
|
103
|
+
playerCount: players,
|
|
104
|
+
currentFrame: 12,
|
|
105
|
+
channels,
|
|
106
|
+
validAssetRefs: ['ability.dash', 'ability.shield'],
|
|
107
|
+
};
|
|
108
|
+
const timeline = createDeterministicCommandTimeline(config);
|
|
109
|
+
const preMigrationCommands = [
|
|
110
|
+
{ id: 'cmd:12:1:dash', slot: 1, frame: 12, channelId: 'ability', payload: validAbilityPayload(1, players, 'ability.dash', 3) },
|
|
111
|
+
{ id: 'cmd:12:1:shield', slot: 1, frame: 12, channelId: 'ability', payload: validAbilityPayload(1, players, 'ability.shield', 4) },
|
|
112
|
+
{ id: 'cmd:13:0:dash', slot: 0, frame: 13, channelId: 'ability', payload: validAbilityPayload(0, players, 'ability.dash', 5) },
|
|
113
|
+
];
|
|
114
|
+
for (const command of preMigrationCommands) {
|
|
115
|
+
timeline.record(command);
|
|
116
|
+
}
|
|
117
|
+
const snapshot = timeline.snapshot();
|
|
118
|
+
const restored = createDeterministicCommandTimeline({ ...config, currentFrame: 0 });
|
|
119
|
+
restored.restore(snapshot);
|
|
120
|
+
const snapshotChecksum = snapshot.checksum;
|
|
121
|
+
const restoredChecksum = restored.snapshot().checksum;
|
|
122
|
+
const restoredCurrentFrameBeforeAdvance = restored.currentFrame === snapshot.currentFrame;
|
|
123
|
+
const pollOrderStable = restored.poll(12, 1, 'ability').map((command) => command.id).join('|') === 'cmd:12:1:dash|cmd:12:1:shield';
|
|
124
|
+
const duplicateAfterRestoreRejected = restored.record({ ...preMigrationCommands[0] })?.reason === 'duplicate-command-id';
|
|
125
|
+
restored.setCurrentFrame(14);
|
|
126
|
+
const staleAfterRestoreRejected = restored.record({
|
|
127
|
+
id: 'cmd:stale',
|
|
128
|
+
slot: 0,
|
|
129
|
+
frame: 13,
|
|
130
|
+
channelId: 'ability',
|
|
131
|
+
payload: validAbilityPayload(0, players, 'ability.dash', 1),
|
|
132
|
+
})?.reason === 'stale-frame';
|
|
133
|
+
const futureAfterRestoreRejected = restored.record({
|
|
134
|
+
id: 'cmd:future',
|
|
135
|
+
slot: 0,
|
|
136
|
+
frame: 40,
|
|
137
|
+
channelId: 'ability',
|
|
138
|
+
payload: validAbilityPayload(0, players, 'ability.dash', 1),
|
|
139
|
+
})?.reason === 'future-frame';
|
|
140
|
+
const unauthorizedAfterRestoreRejected = restored.recordFromSlot(0, {
|
|
141
|
+
id: 'cmd:unauthorized',
|
|
142
|
+
slot: 1,
|
|
143
|
+
frame: 14,
|
|
144
|
+
channelId: 'ability',
|
|
145
|
+
payload: validAbilityPayload(1, players, 'ability.dash', 1),
|
|
146
|
+
})?.reason === 'unauthorized-slot';
|
|
147
|
+
const oversizedAfterRestoreRejected = restored.record({
|
|
148
|
+
id: 'cmd:oversized',
|
|
149
|
+
slot: 0,
|
|
150
|
+
frame: 14,
|
|
151
|
+
channelId: 'ability',
|
|
152
|
+
payload: { ...validAbilityPayload(0, players, 'ability.dash', 1), text: 'x'.repeat(200) },
|
|
153
|
+
})?.reason === 'oversized-payload';
|
|
154
|
+
const badSchemaAfterRestoreRejected = restored.record({
|
|
155
|
+
id: 'cmd:bad-schema',
|
|
156
|
+
slot: 0,
|
|
157
|
+
frame: 14,
|
|
158
|
+
channelId: 'ability',
|
|
159
|
+
payload: { assetRef: 'ability.dash', power: 1, targets: [0] },
|
|
160
|
+
})?.reason === 'bad-schema';
|
|
161
|
+
const badAssetRefAfterRestoreRejected = restored.record({
|
|
162
|
+
id: 'cmd:bad-asset',
|
|
163
|
+
slot: 0,
|
|
164
|
+
frame: 14,
|
|
165
|
+
channelId: 'ability',
|
|
166
|
+
payload: validAbilityPayload(0, players, 'ability.missing', 1),
|
|
167
|
+
})?.reason === 'bad-asset-ref';
|
|
168
|
+
const newCommandAcceptedAfterRestore = restored.record({
|
|
169
|
+
id: 'cmd:14:0:dash',
|
|
170
|
+
slot: 0,
|
|
171
|
+
frame: 14,
|
|
172
|
+
channelId: 'ability',
|
|
173
|
+
payload: validAbilityPayload(0, players, 'ability.dash', 7),
|
|
174
|
+
}) === undefined;
|
|
175
|
+
const rejectionCounts = restored.rejected.reduce((counts, rejection) => {
|
|
176
|
+
counts[rejection.reason] = (counts[rejection.reason] ?? 0) + 1;
|
|
177
|
+
return counts;
|
|
178
|
+
}, {});
|
|
179
|
+
const acceptedCommandIdsRestored = preMigrationCommands.every((command) => restored.accepted.some((candidate) => candidate.id === command.id));
|
|
180
|
+
const currentFrameRestored = snapshot.currentFrame === 12 && restoredCurrentFrameBeforeAdvance;
|
|
181
|
+
const orderedRejectionCounts = {};
|
|
182
|
+
const rejectionReasonOrder = [
|
|
183
|
+
'unauthorized-slot',
|
|
184
|
+
'stale-frame',
|
|
185
|
+
'future-frame',
|
|
186
|
+
'oversized-payload',
|
|
187
|
+
'bad-schema',
|
|
188
|
+
'bad-asset-ref',
|
|
189
|
+
'duplicate-command-id',
|
|
190
|
+
'duplicate-slot-frame-command',
|
|
191
|
+
'unknown-channel',
|
|
192
|
+
'insufficient-frame-lead',
|
|
193
|
+
'prediction-not-allowed',
|
|
194
|
+
];
|
|
195
|
+
for (const reason of rejectionReasonOrder) {
|
|
196
|
+
const count = rejectionCounts[reason] ?? 0;
|
|
197
|
+
if (count > 0) {
|
|
198
|
+
orderedRejectionCounts[reason] = count;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const withoutChecksum = {
|
|
202
|
+
players,
|
|
203
|
+
acceptedBeforeMigration: snapshot.accepted.length,
|
|
204
|
+
acceptedAfterMigration: restored.accepted.length,
|
|
205
|
+
snapshotChecksum,
|
|
206
|
+
restoredChecksum,
|
|
207
|
+
finalChecksum: restored.checksum(),
|
|
208
|
+
pollOrderStable,
|
|
209
|
+
acceptedCommandIdsRestored,
|
|
210
|
+
currentFrameRestored,
|
|
211
|
+
duplicateAfterRestoreRejected,
|
|
212
|
+
staleAfterRestoreRejected,
|
|
213
|
+
futureAfterRestoreRejected,
|
|
214
|
+
unauthorizedAfterRestoreRejected,
|
|
215
|
+
oversizedAfterRestoreRejected,
|
|
216
|
+
badSchemaAfterRestoreRejected,
|
|
217
|
+
badAssetRefAfterRestoreRejected,
|
|
218
|
+
newCommandAcceptedAfterRestore,
|
|
219
|
+
rejectionCounts: orderedRejectionCounts,
|
|
220
|
+
};
|
|
221
|
+
const invariantChecks = [
|
|
222
|
+
pollOrderStable,
|
|
223
|
+
acceptedCommandIdsRestored,
|
|
224
|
+
currentFrameRestored,
|
|
225
|
+
duplicateAfterRestoreRejected,
|
|
226
|
+
staleAfterRestoreRejected,
|
|
227
|
+
futureAfterRestoreRejected,
|
|
228
|
+
unauthorizedAfterRestoreRejected,
|
|
229
|
+
oversizedAfterRestoreRejected,
|
|
230
|
+
badSchemaAfterRestoreRejected,
|
|
231
|
+
badAssetRefAfterRestoreRejected,
|
|
232
|
+
newCommandAcceptedAfterRestore,
|
|
233
|
+
];
|
|
234
|
+
const result = evaluateDeterministicCommandTimelineMigrationResult({
|
|
235
|
+
invariantChecks,
|
|
236
|
+
snapshotChecksum,
|
|
237
|
+
restoredChecksum,
|
|
238
|
+
acceptedBeforeMigration: withoutChecksum.acceptedBeforeMigration,
|
|
239
|
+
acceptedAfterMigration: withoutChecksum.acceptedAfterMigration,
|
|
240
|
+
expectedAcceptedBeforeMigration: preMigrationCommands.length,
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
result,
|
|
244
|
+
...withoutChecksum,
|
|
245
|
+
checksum: defaultChecksum(withoutChecksum),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export function evaluateDeterministicCommandTimelineMigrationResult(input) {
|
|
249
|
+
return input.invariantChecks.every((value) => value)
|
|
250
|
+
&& input.snapshotChecksum === input.restoredChecksum
|
|
251
|
+
&& input.acceptedBeforeMigration === input.expectedAcceptedBeforeMigration
|
|
252
|
+
&& input.acceptedAfterMigration === input.expectedAcceptedBeforeMigration + 1
|
|
253
|
+
? 'pass'
|
|
254
|
+
: 'fail';
|
|
255
|
+
}
|
|
256
|
+
export function createDeterministicCommandTimelineSnapshot(snapshot) {
|
|
257
|
+
const normalized = {
|
|
258
|
+
currentFrame: snapshot.currentFrame,
|
|
259
|
+
accepted: sortCommands(snapshot.accepted).map((command) => cloneCanonical(command)),
|
|
260
|
+
rejected: [...snapshot.rejected].sort(compareRejectedCommands).map((command) => cloneCanonical(command)),
|
|
261
|
+
};
|
|
262
|
+
return {
|
|
263
|
+
...normalized,
|
|
264
|
+
checksum: defaultChecksum(normalized),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
export function runDeterministicCommandProtocolFixture(players, frames, seed) {
|
|
268
|
+
if (!Number.isInteger(players) || players < 1) {
|
|
269
|
+
throw new Error('command protocol players must be at least 1');
|
|
270
|
+
}
|
|
271
|
+
if (!Number.isInteger(frames) || frames < 1) {
|
|
272
|
+
throw new Error('command protocol frames must be at least 1');
|
|
273
|
+
}
|
|
274
|
+
const random = new DeterministicRandom(seed);
|
|
275
|
+
const channelDescriptors = [
|
|
276
|
+
{
|
|
277
|
+
id: 'ability',
|
|
278
|
+
reliable: true,
|
|
279
|
+
allowPrediction: true,
|
|
280
|
+
maxPayloadBytes: 128,
|
|
281
|
+
maxFrameLead: 12,
|
|
282
|
+
fields: [
|
|
283
|
+
{ name: 'assetRef', kind: 'asset-ref' },
|
|
284
|
+
{ name: 'power', kind: 'integer', min: 0, max: 9 },
|
|
285
|
+
{ name: 'targets', kind: 'player-slot[]', minItems: 1, maxItems: 4, min: 0, max: Math.max(0, players - 1) },
|
|
286
|
+
{ name: 'loadout', kind: 'object', fields: [
|
|
287
|
+
{ name: 'assetRef', kind: 'asset-ref' },
|
|
288
|
+
{ name: 'modifiers', kind: 'integer[]', minItems: 1, maxItems: 3, min: 0, max: 9 },
|
|
289
|
+
] },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'emote',
|
|
294
|
+
reliable: false,
|
|
295
|
+
allowPrediction: false,
|
|
296
|
+
maxPayloadBytes: 64,
|
|
297
|
+
minFrameLead: 1,
|
|
298
|
+
maxFrameLead: 4,
|
|
299
|
+
fields: [{ name: 'assetRef', kind: 'asset-ref' }],
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
const channelById = new Map(channelDescriptors.map((channel) => [channel.id, channel]));
|
|
303
|
+
const timeline = createDeterministicCommandTimeline({
|
|
304
|
+
playerCount: players,
|
|
305
|
+
currentFrame: 0,
|
|
306
|
+
channels: channelDescriptors,
|
|
307
|
+
validAssetRefs: ['ability.dash', 'ability.shield', 'emote.wave'],
|
|
308
|
+
});
|
|
309
|
+
const delays = [];
|
|
310
|
+
const rollbackDepths = [];
|
|
311
|
+
let duplicateDrops = 0;
|
|
312
|
+
let multiCommandSameFrameAccepted = 0;
|
|
313
|
+
let multiCommandPollOrderStable = false;
|
|
314
|
+
let acceptedFutureCommands = 0;
|
|
315
|
+
let reliableDelivered = 0;
|
|
316
|
+
let reliableResent = 0;
|
|
317
|
+
let unreliableDropped = 0;
|
|
318
|
+
const transport = createDeterministicCommandTransport();
|
|
319
|
+
if (frames > 2) {
|
|
320
|
+
const fixtureFutureCommand = {
|
|
321
|
+
id: 'fixture:future-emote',
|
|
322
|
+
slot: 0,
|
|
323
|
+
frame: 2,
|
|
324
|
+
channelId: 'emote',
|
|
325
|
+
payload: { assetRef: 'emote.wave' },
|
|
326
|
+
};
|
|
327
|
+
timeline.record(fixtureFutureCommand);
|
|
328
|
+
acceptedFutureCommands += 1;
|
|
329
|
+
transport.send(fixtureFutureCommand, channelById.get(fixtureFutureCommand.channelId), 0);
|
|
330
|
+
}
|
|
331
|
+
if (frames > 1) {
|
|
332
|
+
const multiCommands = [
|
|
333
|
+
{
|
|
334
|
+
id: 'fixture:multi:reload',
|
|
335
|
+
slot: 0,
|
|
336
|
+
frame: 0,
|
|
337
|
+
channelId: 'ability',
|
|
338
|
+
payload: { assetRef: 'ability.shield', power: 3, targets: [0, 1], loadout: { assetRef: 'ability.shield', modifiers: [1, 2] } },
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
id: 'fixture:multi:dash',
|
|
342
|
+
slot: 0,
|
|
343
|
+
frame: 0,
|
|
344
|
+
channelId: 'ability',
|
|
345
|
+
payload: { assetRef: 'ability.dash', power: 7, targets: [0], loadout: { assetRef: 'ability.dash', modifiers: [3] } },
|
|
346
|
+
},
|
|
347
|
+
];
|
|
348
|
+
for (const command of multiCommands) {
|
|
349
|
+
timeline.record(command);
|
|
350
|
+
multiCommandSameFrameAccepted += 1;
|
|
351
|
+
transport.send(command, channelById.get(command.channelId), 0);
|
|
352
|
+
}
|
|
353
|
+
multiCommandPollOrderStable = timeline.poll(0, 0, 'ability').map((command) => command.id).join('|')
|
|
354
|
+
=== 'fixture:multi:dash|fixture:multi:reload';
|
|
355
|
+
}
|
|
356
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
357
|
+
timeline.setCurrentFrame(frame);
|
|
358
|
+
for (let slot = 0; slot < players; slot += 1) {
|
|
359
|
+
if ((slot + frame + seed) % 17 !== 0) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const delay = random.nextInt(0, 5);
|
|
363
|
+
if ((slot + frame + seed) % 53 === 0 && frame + 2 < frames) {
|
|
364
|
+
const futureCommand = {
|
|
365
|
+
id: `${slot}:${frame}:emote`,
|
|
366
|
+
slot,
|
|
367
|
+
frame: frame + 2,
|
|
368
|
+
channelId: 'emote',
|
|
369
|
+
payload: { assetRef: 'emote.wave' },
|
|
370
|
+
};
|
|
371
|
+
timeline.record(futureCommand);
|
|
372
|
+
acceptedFutureCommands += 1;
|
|
373
|
+
transport.send(futureCommand, channelById.get(futureCommand.channelId), frame);
|
|
374
|
+
}
|
|
375
|
+
const command = {
|
|
376
|
+
id: `${slot}:${frame}:ability`,
|
|
377
|
+
slot,
|
|
378
|
+
frame,
|
|
379
|
+
channelId: 'ability',
|
|
380
|
+
payload: {
|
|
381
|
+
assetRef: slot % 2 === 0 ? 'ability.dash' : 'ability.shield',
|
|
382
|
+
power: (slot + frame) % 10,
|
|
383
|
+
targets: [slot, (slot + 1) % players],
|
|
384
|
+
loadout: {
|
|
385
|
+
assetRef: slot % 2 === 0 ? 'ability.dash' : 'ability.shield',
|
|
386
|
+
modifiers: [(slot + frame) % 4, (slot + frame + 1) % 4],
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
timeline.record(command);
|
|
391
|
+
const rejection = timeline.record({ ...command, id: command.id });
|
|
392
|
+
duplicateDrops += Number(rejection?.reason === 'duplicate-command-id');
|
|
393
|
+
transport.send(command, channelById.get(command.channelId), frame);
|
|
394
|
+
delays.push(delay);
|
|
395
|
+
rollbackDepths.push(delay === 0 ? 0 : Math.min(8, delay + (frame % 3)));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const transportMetrics = transport.flush(frames + 16);
|
|
399
|
+
reliableDelivered = transportMetrics.reliableDelivered;
|
|
400
|
+
reliableResent = transportMetrics.reliableResent;
|
|
401
|
+
unreliableDropped = transportMetrics.unreliableDropped;
|
|
402
|
+
const hostileFrame = frames - 1;
|
|
403
|
+
const hostile = [
|
|
404
|
+
{ id: 'bad-slot', slot: players, frame: hostileFrame, channelId: 'ability', payload: validAbilityPayload(0, players, 'ability.dash', 1) },
|
|
405
|
+
{ id: 'stale', slot: 0, frame: -1, channelId: 'ability', payload: validAbilityPayload(0, players, 'ability.dash', 1) },
|
|
406
|
+
{ id: 'future', slot: 0, frame: frames + 99, channelId: 'ability', payload: validAbilityPayload(0, players, 'ability.dash', 1) },
|
|
407
|
+
{ id: 'oversized', slot: 0, frame: hostileFrame, channelId: 'ability', payload: { ...validAbilityPayload(0, players, 'ability.dash', 1), text: 'x'.repeat(200) } },
|
|
408
|
+
{ id: 'bad-schema', slot: 0, frame: hostileFrame, channelId: 'ability', payload: 'bad' },
|
|
409
|
+
{ id: 'bad-schema-missing', slot: 0, frame: hostileFrame, channelId: 'ability', payload: { assetRef: 'ability.dash', power: 1, targets: [0] } },
|
|
410
|
+
{ id: 'bad-schema-extra', slot: 0, frame: hostileFrame, channelId: 'ability', payload: { ...validAbilityPayload(0, players, 'ability.dash', 1), extra: 1 } },
|
|
411
|
+
{ id: 'bad-schema-range', slot: 0, frame: hostileFrame, channelId: 'ability', payload: validAbilityPayload(0, players, 'ability.dash', 99) },
|
|
412
|
+
{ id: 'bad-schema-array-length', slot: 0, frame: hostileFrame, channelId: 'ability', payload: { ...validAbilityPayload(0, players, 'ability.dash', 1), targets: [0, 1, 2, 3, 4] } },
|
|
413
|
+
{ id: 'bad-schema-nested-array-type', slot: 0, frame: hostileFrame, channelId: 'ability', payload: { ...validAbilityPayload(0, players, 'ability.dash', 1), loadout: { assetRef: 'ability.dash', modifiers: [1, 'bad'] } } },
|
|
414
|
+
{ id: 'bad-asset', slot: 0, frame: hostileFrame, channelId: 'ability', payload: validAbilityPayload(0, players, 'missing.asset', 1) },
|
|
415
|
+
{ id: 'bad-channel', slot: 0, frame: hostileFrame, channelId: 'missing', payload: { assetRef: 'ability.dash' } },
|
|
416
|
+
{ id: 'too-soon', slot: 0, frame: hostileFrame, channelId: 'emote', payload: { assetRef: 'emote.wave' } },
|
|
417
|
+
{ id: 'predicted-emote', slot: 0, frame: frames, channelId: 'emote', payload: { assetRef: 'emote.wave' }, predicted: true },
|
|
418
|
+
];
|
|
419
|
+
timeline.setCurrentFrame(frames - 1);
|
|
420
|
+
hostile.forEach((command) => timeline.record(command));
|
|
421
|
+
const replayed = createDeterministicCommandTimeline({
|
|
422
|
+
playerCount: players,
|
|
423
|
+
currentFrame: 0,
|
|
424
|
+
channels: channelDescriptors,
|
|
425
|
+
validAssetRefs: ['ability.dash', 'ability.shield', 'emote.wave'],
|
|
426
|
+
});
|
|
427
|
+
for (const command of [...timeline.accepted].sort(compareCommandsBySubmission)) {
|
|
428
|
+
replayed.setCurrentFrame(command.submittedFrame);
|
|
429
|
+
replayed.record(command);
|
|
430
|
+
}
|
|
431
|
+
const replayedFutureCommands = replayed.accepted.filter((command) => command.submittedFrame < command.frame).length;
|
|
432
|
+
const reliableAcceptedOrder = transportMetrics.reliableOrder;
|
|
433
|
+
const replayTransport = createDeterministicCommandTransport();
|
|
434
|
+
for (const command of replayed.accepted) {
|
|
435
|
+
replayTransport.send(command, channelById.get(command.channelId), command.submittedFrame);
|
|
436
|
+
}
|
|
437
|
+
const reliableReplayedOrder = replayTransport.flush(frames + 16).reliableOrder;
|
|
438
|
+
const schemaRejected = timeline.rejected.filter((command) => command.reason === 'bad-schema').length;
|
|
439
|
+
return {
|
|
440
|
+
checksum: timeline.checksum(),
|
|
441
|
+
delivered: timeline.accepted.length,
|
|
442
|
+
rejected: timeline.rejected.length,
|
|
443
|
+
duplicateDrops,
|
|
444
|
+
multiCommandSameFrameAccepted,
|
|
445
|
+
multiCommandPollOrderStable,
|
|
446
|
+
acceptedFutureCommands,
|
|
447
|
+
replayedFutureCommands,
|
|
448
|
+
reliableDelivered,
|
|
449
|
+
reliableResent,
|
|
450
|
+
unreliableDropped,
|
|
451
|
+
reliableOrderingStable: defaultChecksum(reliableAcceptedOrder) === defaultChecksum(reliableReplayedOrder),
|
|
452
|
+
firstDeliveredFrame: timeline.accepted[0]?.frame ?? -1,
|
|
453
|
+
lastDeliveredFrame: timeline.accepted[timeline.accepted.length - 1]?.frame ?? -1,
|
|
454
|
+
delayP50Frames: percentile(delays, 50),
|
|
455
|
+
delayP95Frames: percentile(delays, 95),
|
|
456
|
+
rollbackDepthP95: percentile(rollbackDepths, 95),
|
|
457
|
+
schemaRejected,
|
|
458
|
+
schemaDeclaredPayloadsCovered: schemaRejected >= 4
|
|
459
|
+
&& timeline.accepted.some((command) => command.channelId === 'ability')
|
|
460
|
+
&& timeline.accepted.some((command) => command.channelId === 'emote'),
|
|
461
|
+
schemaCollectionPayloadsCovered: timeline.accepted.filter((command) => command.channelId === 'ability').some((command) => {
|
|
462
|
+
const payload = command.payload;
|
|
463
|
+
const loadout = payload.loadout;
|
|
464
|
+
return Array.isArray(payload.targets)
|
|
465
|
+
&& typeof loadout === 'object'
|
|
466
|
+
&& loadout !== null
|
|
467
|
+
&& !Array.isArray(loadout)
|
|
468
|
+
&& Array.isArray(loadout.modifiers);
|
|
469
|
+
})
|
|
470
|
+
&& timeline.rejected.some((command) => command.commandId === 'bad-schema-array-length')
|
|
471
|
+
&& timeline.rejected.some((command) => command.commandId === 'bad-schema-nested-array-type'),
|
|
472
|
+
convergence: defaultChecksum(timeline.accepted) === defaultChecksum(replayed.accepted),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
export function runDeterministicReliableCommandDefaultsFixture(players, frames, seed) {
|
|
476
|
+
if (!Number.isInteger(players) || players < 1) {
|
|
477
|
+
throw new Error('reliable command defaults players must be at least 1');
|
|
478
|
+
}
|
|
479
|
+
if (!Number.isInteger(frames) || frames < 1) {
|
|
480
|
+
throw new Error('reliable command defaults frames must be at least 1');
|
|
481
|
+
}
|
|
482
|
+
const channels = [{
|
|
483
|
+
id: 'command',
|
|
484
|
+
reliable: true,
|
|
485
|
+
allowPrediction: true,
|
|
486
|
+
maxPayloadBytes: 128,
|
|
487
|
+
maxFrameLead: 8,
|
|
488
|
+
fields: [
|
|
489
|
+
{ name: 'power', kind: 'integer', min: 0, max: 9 },
|
|
490
|
+
{ name: 'target', kind: 'player-slot', min: 0, max: Math.max(0, players - 1) },
|
|
491
|
+
],
|
|
492
|
+
}];
|
|
493
|
+
const timeline = createDeterministicCommandTimeline({
|
|
494
|
+
playerCount: players,
|
|
495
|
+
currentFrame: 0,
|
|
496
|
+
channels,
|
|
497
|
+
});
|
|
498
|
+
const transport = createDeterministicCommandTransport();
|
|
499
|
+
const acceptedCommands = [];
|
|
500
|
+
const random = new DeterministicRandom(seed + 811);
|
|
501
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
502
|
+
timeline.setCurrentFrame(frame);
|
|
503
|
+
const burstCount = frame % 7 === 0 ? 3 : 1;
|
|
504
|
+
for (let index = 0; index < burstCount; index += 1) {
|
|
505
|
+
const slot = (frame + index + random.nextInt(0, Math.max(0, players - 1))) % players;
|
|
506
|
+
const command = {
|
|
507
|
+
id: `reliable:${frame}:${index}:${slot}`,
|
|
508
|
+
slot,
|
|
509
|
+
frame,
|
|
510
|
+
channelId: 'command',
|
|
511
|
+
payload: {
|
|
512
|
+
power: (frame + index + seed) % 10,
|
|
513
|
+
target: (slot + index + 1) % players,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
timeline.record(command);
|
|
517
|
+
acceptedCommands.push({ ...command, submittedFrame: frame });
|
|
518
|
+
transport.send(command, channels[0], frame);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const duplicateRejected = Number(timeline.record(acceptedCommands[0])?.reason === 'duplicate-command-id');
|
|
522
|
+
timeline.setCurrentFrame(frames);
|
|
523
|
+
const staleRejected = Number(timeline.record({
|
|
524
|
+
id: 'reliable:stale',
|
|
525
|
+
slot: 0,
|
|
526
|
+
frame: Math.max(0, frames - 1),
|
|
527
|
+
channelId: 'command',
|
|
528
|
+
payload: { power: 1, target: 0 },
|
|
529
|
+
})?.reason === 'stale-frame');
|
|
530
|
+
const futureRejected = Number(timeline.record({
|
|
531
|
+
id: 'reliable:future',
|
|
532
|
+
slot: 0,
|
|
533
|
+
frame: frames + 99,
|
|
534
|
+
channelId: 'command',
|
|
535
|
+
payload: { power: 1, target: 0 },
|
|
536
|
+
})?.reason === 'future-frame');
|
|
537
|
+
const transportMetrics = transport.flush(frames + 32);
|
|
538
|
+
const replayed = createDeterministicCommandTimeline({
|
|
539
|
+
playerCount: players,
|
|
540
|
+
currentFrame: 0,
|
|
541
|
+
channels,
|
|
542
|
+
});
|
|
543
|
+
for (const command of acceptedCommands) {
|
|
544
|
+
replayed.setCurrentFrame(command.submittedFrame);
|
|
545
|
+
replayed.record(command);
|
|
546
|
+
}
|
|
547
|
+
const originalOrderHash = defaultChecksum(transportMetrics.reliableOrder);
|
|
548
|
+
const replayTransport = createDeterministicCommandTransport();
|
|
549
|
+
for (const command of replayed.accepted) {
|
|
550
|
+
replayTransport.send(command, channels[0], command.submittedFrame);
|
|
551
|
+
}
|
|
552
|
+
const replayOrderHash = defaultChecksum(replayTransport.flush(frames + 32).reliableOrder);
|
|
553
|
+
const pollOrderStable = timeline.poll(0)
|
|
554
|
+
.map((command) => command.id)
|
|
555
|
+
.join('|') === acceptedCommands.filter((command) => command.frame === 0).sort(compareCommands).map((command) => command.id).join('|');
|
|
556
|
+
const metrics = {
|
|
557
|
+
players,
|
|
558
|
+
frames,
|
|
559
|
+
accepted: acceptedCommands.length,
|
|
560
|
+
reliableDelivered: transportMetrics.reliableDelivered,
|
|
561
|
+
reliableResent: transportMetrics.reliableResent,
|
|
562
|
+
unreliableDropped: transportMetrics.unreliableDropped,
|
|
563
|
+
duplicateRejected,
|
|
564
|
+
staleRejected,
|
|
565
|
+
futureRejected,
|
|
566
|
+
pollOrderStable,
|
|
567
|
+
replayConverged: defaultChecksum(timeline.accepted) === defaultChecksum(replayed.accepted),
|
|
568
|
+
originalOrderHash,
|
|
569
|
+
replayOrderHash,
|
|
570
|
+
};
|
|
571
|
+
return { ...metrics, checksum: defaultChecksum(metrics) };
|
|
572
|
+
}
|
|
573
|
+
function validAbilityPayload(slot, players, assetRef, power) {
|
|
574
|
+
return {
|
|
575
|
+
assetRef,
|
|
576
|
+
power,
|
|
577
|
+
targets: [slot, (slot + 1) % Math.max(1, players)],
|
|
578
|
+
loadout: {
|
|
579
|
+
assetRef,
|
|
580
|
+
modifiers: [power % 4, (power + 1) % 4],
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function createDeterministicCommandTransport() {
|
|
585
|
+
const pending = [];
|
|
586
|
+
const reliableOrder = [];
|
|
587
|
+
let reliableDelivered = 0;
|
|
588
|
+
let reliableResent = 0;
|
|
589
|
+
let unreliableDropped = 0;
|
|
590
|
+
return {
|
|
591
|
+
send(command, channel, submittedFrame) {
|
|
592
|
+
pending.push({ command, channel, attempt: 0, deliveryFrame: submittedFrame + deterministicTransportDelay(command) });
|
|
593
|
+
},
|
|
594
|
+
flush(untilFrame) {
|
|
595
|
+
const retries = [];
|
|
596
|
+
const due = pending
|
|
597
|
+
.filter((entry) => entry.deliveryFrame <= untilFrame)
|
|
598
|
+
.sort(compareTransportEntries);
|
|
599
|
+
pending.length = 0;
|
|
600
|
+
for (const entry of due) {
|
|
601
|
+
const shouldDrop = deterministicTransportDrops(entry.command, entry.attempt);
|
|
602
|
+
if (shouldDrop && entry.channel.reliable) {
|
|
603
|
+
reliableResent += 1;
|
|
604
|
+
retries.push({ ...entry, attempt: entry.attempt + 1, deliveryFrame: entry.deliveryFrame + 1 });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (shouldDrop) {
|
|
608
|
+
unreliableDropped += 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
reliableDelivered += 1;
|
|
612
|
+
reliableOrder.push(entry.command.id);
|
|
613
|
+
}
|
|
614
|
+
for (const entry of retries.filter((candidate) => candidate.deliveryFrame <= untilFrame).sort(compareTransportEntries)) {
|
|
615
|
+
reliableDelivered += 1;
|
|
616
|
+
reliableOrder.push(entry.command.id);
|
|
617
|
+
}
|
|
618
|
+
return { reliableDelivered, reliableResent, unreliableDropped, reliableOrder: [...reliableOrder] };
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function compareTransportEntries(left, right) {
|
|
623
|
+
return left.deliveryFrame - right.deliveryFrame || compareCommands(left.command, right.command);
|
|
624
|
+
}
|
|
625
|
+
function deterministicTransportDelay(command) {
|
|
626
|
+
return 1 + (stableCommandNumber(command.id) % 4);
|
|
627
|
+
}
|
|
628
|
+
function deterministicTransportDrops(command, attempt) {
|
|
629
|
+
const hash = stableCommandNumber(command.id);
|
|
630
|
+
if (command.channelId === 'ability') {
|
|
631
|
+
return attempt === 0 && hash % 64 === 0;
|
|
632
|
+
}
|
|
633
|
+
return attempt === 0;
|
|
634
|
+
}
|
|
635
|
+
function stableCommandNumber(id) {
|
|
636
|
+
let hash = 0;
|
|
637
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
638
|
+
hash = (hash * 31 + id.charCodeAt(index)) % 1_000_003;
|
|
639
|
+
}
|
|
640
|
+
return hash;
|
|
641
|
+
}
|
|
642
|
+
function validateCommand(config, channels, validAssetRefs, commandIds, command) {
|
|
643
|
+
if (commandIds.has(command.id)) {
|
|
644
|
+
return reject(command, 'duplicate-command-id');
|
|
645
|
+
}
|
|
646
|
+
if (!Number.isInteger(command.slot) || command.slot < 0 || command.slot >= config.playerCount) {
|
|
647
|
+
return reject(command, 'unauthorized-slot');
|
|
648
|
+
}
|
|
649
|
+
if (!Number.isInteger(command.frame) || command.frame < 0) {
|
|
650
|
+
return reject(command, 'stale-frame');
|
|
651
|
+
}
|
|
652
|
+
if (command.frame < config.currentFrame) {
|
|
653
|
+
return reject(command, 'stale-frame');
|
|
654
|
+
}
|
|
655
|
+
const channel = channels.get(command.channelId);
|
|
656
|
+
if (channel === undefined) {
|
|
657
|
+
return reject(command, 'unknown-channel');
|
|
658
|
+
}
|
|
659
|
+
if (command.frame > config.currentFrame + (channel.maxFrameLead ?? 60)) {
|
|
660
|
+
return reject(command, 'future-frame');
|
|
661
|
+
}
|
|
662
|
+
if (command.frame < config.currentFrame + (channel.minFrameLead ?? 0)) {
|
|
663
|
+
return reject(command, 'insufficient-frame-lead');
|
|
664
|
+
}
|
|
665
|
+
if (command.predicted === true && channel.allowPrediction !== true) {
|
|
666
|
+
return reject(command, 'prediction-not-allowed');
|
|
667
|
+
}
|
|
668
|
+
if (canonicalStringify(command.payload).length > channel.maxPayloadBytes) {
|
|
669
|
+
return reject(command, 'oversized-payload');
|
|
670
|
+
}
|
|
671
|
+
if (channel.fields !== undefined && !deterministicPayloadMatchesFields(command.payload, channel.fields)) {
|
|
672
|
+
return reject(command, 'bad-schema');
|
|
673
|
+
}
|
|
674
|
+
if (config.validatePayload?.(command) === false) {
|
|
675
|
+
return reject(command, 'bad-schema');
|
|
676
|
+
}
|
|
677
|
+
for (const assetRef of collectCommandAssetRefs(command.payload, channel.fields)) {
|
|
678
|
+
if (validAssetRefs.size > 0 && !validAssetRefs.has(assetRef)) {
|
|
679
|
+
return reject(command, 'bad-asset-ref');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
export function deterministicPayloadMatchesFields(payload, fields) {
|
|
685
|
+
try {
|
|
686
|
+
assertDeterministicPayloadFields('payload', payload, fields);
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
export function assertDeterministicPayloadFields(label, payload, fields) {
|
|
694
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
695
|
+
throw new Error(`${label} must be a deterministic object payload`);
|
|
696
|
+
}
|
|
697
|
+
const record = payload;
|
|
698
|
+
const declared = new Map(fields.map((field) => [field.name, field]));
|
|
699
|
+
for (const field of fields) {
|
|
700
|
+
if (!Object.prototype.hasOwnProperty.call(record, field.name)) {
|
|
701
|
+
if (field.optional === true) {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
throw new Error(`${label}.${field.name} is missing`);
|
|
705
|
+
}
|
|
706
|
+
assertDeterministicPayloadFieldValue(`${label}.${field.name}`, record[field.name], field);
|
|
707
|
+
}
|
|
708
|
+
for (const key of stableRecordKeys(record)) {
|
|
709
|
+
if (!declared.has(key)) {
|
|
710
|
+
throw new Error(`${label}.${key} is not declared`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function assertDeterministicPayloadFieldValue(label, value, field) {
|
|
715
|
+
if (field.kind === 'object') {
|
|
716
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
717
|
+
throw new Error(`${label} must be object`);
|
|
718
|
+
}
|
|
719
|
+
assertDeterministicPayloadFields(label, value, field.fields ?? []);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (field.kind === 'object[]') {
|
|
723
|
+
assertDeterministicPayloadArrayLength(label, value, field);
|
|
724
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
725
|
+
assertDeterministicPayloadFieldValue(`${label}[${index}]`, value[index], { ...field, kind: 'object' });
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (isPayloadArrayKind(field.kind)) {
|
|
730
|
+
assertDeterministicPayloadArrayLength(label, value, field);
|
|
731
|
+
const elementKind = field.kind.slice(0, -2);
|
|
732
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
733
|
+
assertDeterministicPayloadScalarFieldValue(`${label}[${index}]`, value[index], { ...field, kind: elementKind });
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (Array.isArray(value)) {
|
|
738
|
+
throw new Error(`${label} arrays require a declared collection schema`);
|
|
739
|
+
}
|
|
740
|
+
assertDeterministicPayloadScalarFieldValue(label, value, field);
|
|
741
|
+
}
|
|
742
|
+
function assertDeterministicPayloadArrayLength(label, value, field) {
|
|
743
|
+
if (!Array.isArray(value)) {
|
|
744
|
+
throw new Error(`${label} must be array`);
|
|
745
|
+
}
|
|
746
|
+
if (field.minItems !== undefined && value.length < field.minItems) {
|
|
747
|
+
throw new Error(`${label} has fewer than ${field.minItems} items`);
|
|
748
|
+
}
|
|
749
|
+
if (field.maxItems !== undefined && value.length > field.maxItems) {
|
|
750
|
+
throw new Error(`${label} has more than ${field.maxItems} items`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function assertDeterministicPayloadScalarFieldValue(label, value, field) {
|
|
754
|
+
switch (field.kind) {
|
|
755
|
+
case 'boolean':
|
|
756
|
+
case 'button':
|
|
757
|
+
if (typeof value !== 'boolean') {
|
|
758
|
+
throw new Error(`${label} must be boolean`);
|
|
759
|
+
}
|
|
760
|
+
return;
|
|
761
|
+
case 'string':
|
|
762
|
+
case 'asset-ref':
|
|
763
|
+
if (typeof value !== 'string') {
|
|
764
|
+
throw new Error(`${label} must be string`);
|
|
765
|
+
}
|
|
766
|
+
return;
|
|
767
|
+
case 'integer':
|
|
768
|
+
case 'fixed-q16':
|
|
769
|
+
case 'bitfield':
|
|
770
|
+
case 'player-slot':
|
|
771
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
772
|
+
throw new Error(`${label} must be integer`);
|
|
773
|
+
}
|
|
774
|
+
if (field.min !== undefined && value < field.min) {
|
|
775
|
+
throw new Error(`${label} is below minimum ${field.min}`);
|
|
776
|
+
}
|
|
777
|
+
if (field.max !== undefined && value > field.max) {
|
|
778
|
+
throw new Error(`${label} is above maximum ${field.max}`);
|
|
779
|
+
}
|
|
780
|
+
return;
|
|
781
|
+
default:
|
|
782
|
+
throw new Error(`${label} has unknown deterministic payload kind ${field.kind}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function isPayloadArrayKind(kind) {
|
|
786
|
+
return kind.endsWith('[]') && kind !== 'object[]';
|
|
787
|
+
}
|
|
788
|
+
function validateCommandTimelineConfig(config) {
|
|
789
|
+
if (!Number.isInteger(config.playerCount) || config.playerCount < 1) {
|
|
790
|
+
throw new Error('Command timeline playerCount must be a positive integer');
|
|
791
|
+
}
|
|
792
|
+
if (!Number.isInteger(config.currentFrame) || config.currentFrame < 0) {
|
|
793
|
+
throw new Error('Command timeline currentFrame must be a non-negative integer');
|
|
794
|
+
}
|
|
795
|
+
const channelIds = new Set();
|
|
796
|
+
for (const channel of config.channels) {
|
|
797
|
+
if (channelIds.has(channel.id)) {
|
|
798
|
+
throw new Error(`Duplicate command channel id ${channel.id}`);
|
|
799
|
+
}
|
|
800
|
+
channelIds.add(channel.id);
|
|
801
|
+
if (!Number.isInteger(channel.maxPayloadBytes) || channel.maxPayloadBytes < 0) {
|
|
802
|
+
throw new Error(`Command channel ${channel.id} maxPayloadBytes must be a non-negative integer`);
|
|
803
|
+
}
|
|
804
|
+
if (channel.minFrameLead !== undefined && (!Number.isInteger(channel.minFrameLead) || channel.minFrameLead < 0)) {
|
|
805
|
+
throw new Error(`Command channel ${channel.id} minFrameLead must be a non-negative integer`);
|
|
806
|
+
}
|
|
807
|
+
if (channel.maxFrameLead !== undefined && (!Number.isInteger(channel.maxFrameLead) || channel.maxFrameLead < 0)) {
|
|
808
|
+
throw new Error(`Command channel ${channel.id} maxFrameLead must be a non-negative integer`);
|
|
809
|
+
}
|
|
810
|
+
if (channel.fields !== undefined) {
|
|
811
|
+
validateDeterministicPayloadFieldDescriptors(`channel ${channel.id}`, channel.fields);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function validateDeterministicPayloadFieldDescriptors(label, fields) {
|
|
816
|
+
const names = new Set();
|
|
817
|
+
for (const field of fields) {
|
|
818
|
+
if (names.has(field.name)) {
|
|
819
|
+
throw new Error(`${label}.${field.name} is declared more than once`);
|
|
820
|
+
}
|
|
821
|
+
names.add(field.name);
|
|
822
|
+
assertKnownDeterministicPayloadKind(`${label}.${field.name}`, field.kind);
|
|
823
|
+
if ((field.kind === 'object' || field.kind === 'object[]') && field.fields !== undefined) {
|
|
824
|
+
validateDeterministicPayloadFieldDescriptors(`${label}.${field.name}`, field.fields);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function assertKnownDeterministicPayloadKind(label, kind) {
|
|
829
|
+
if (kind === 'object' ||
|
|
830
|
+
kind === 'object[]' ||
|
|
831
|
+
kind === 'boolean' ||
|
|
832
|
+
kind === 'button' ||
|
|
833
|
+
kind === 'string' ||
|
|
834
|
+
kind === 'asset-ref' ||
|
|
835
|
+
kind === 'integer' ||
|
|
836
|
+
kind === 'fixed-q16' ||
|
|
837
|
+
kind === 'bitfield' ||
|
|
838
|
+
kind === 'player-slot' ||
|
|
839
|
+
kind === 'boolean[]' ||
|
|
840
|
+
kind === 'button[]' ||
|
|
841
|
+
kind === 'string[]' ||
|
|
842
|
+
kind === 'asset-ref[]' ||
|
|
843
|
+
kind === 'integer[]' ||
|
|
844
|
+
kind === 'fixed-q16[]' ||
|
|
845
|
+
kind === 'bitfield[]' ||
|
|
846
|
+
kind === 'player-slot[]') {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
throw new Error(`${label} has unknown deterministic payload kind ${kind}`);
|
|
850
|
+
}
|
|
851
|
+
function validateRestoredCommands(config, channels, validAssetRefs, snapshot) {
|
|
852
|
+
const restoredIds = new Set();
|
|
853
|
+
const restoredAccepted = [];
|
|
854
|
+
for (const command of snapshot.accepted) {
|
|
855
|
+
const restoredCommand = cloneCanonical(command);
|
|
856
|
+
const validation = validateCommand({ ...config, currentFrame: Math.min(snapshot.currentFrame, restoredCommand.frame) }, channels, validAssetRefs, restoredIds, restoredCommand);
|
|
857
|
+
if (validation !== undefined) {
|
|
858
|
+
throw new Error(`Invalid restored deterministic command ${restoredCommand.id}: ${validation.reason}`);
|
|
859
|
+
}
|
|
860
|
+
const submittedFrame = restoredCommand.submittedFrame ?? restoredCommand.frame;
|
|
861
|
+
if (submittedFrame > restoredCommand.frame) {
|
|
862
|
+
throw new Error(`Invalid restored deterministic command ${restoredCommand.id}: submittedFrame exceeds command frame`);
|
|
863
|
+
}
|
|
864
|
+
restoredIds.add(restoredCommand.id);
|
|
865
|
+
restoredAccepted.push({ ...restoredCommand, submittedFrame });
|
|
866
|
+
}
|
|
867
|
+
return restoredAccepted;
|
|
868
|
+
}
|
|
869
|
+
function collectCommandAssetRefs(value, fields) {
|
|
870
|
+
const refs = [];
|
|
871
|
+
const visitBySchema = (record, descriptors) => {
|
|
872
|
+
for (const field of descriptors) {
|
|
873
|
+
if (!Object.prototype.hasOwnProperty.call(record, field.name)) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const child = record[field.name];
|
|
877
|
+
if (field.kind === 'asset-ref' && typeof child === 'string') {
|
|
878
|
+
refs.push(child);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (field.kind === 'asset-ref[]' && Array.isArray(child)) {
|
|
882
|
+
for (const ref of child) {
|
|
883
|
+
refs.push(ref);
|
|
884
|
+
}
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (field.kind === 'object') {
|
|
888
|
+
visitBySchema(child, field.fields ?? []);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (field.kind === 'object[]' && Array.isArray(child)) {
|
|
892
|
+
for (const item of child) {
|
|
893
|
+
visitBySchema(item, field.fields ?? []);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
const visitByConvention = (candidate) => {
|
|
899
|
+
if (Array.isArray(candidate)) {
|
|
900
|
+
for (const item of candidate) {
|
|
901
|
+
visitByConvention(item);
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (typeof candidate !== 'object' || candidate === null) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const record = candidate;
|
|
909
|
+
for (const key of stableRecordKeys(record)) {
|
|
910
|
+
const child = record[key];
|
|
911
|
+
if (key === 'assetRef' && typeof child === 'string') {
|
|
912
|
+
refs.push(child);
|
|
913
|
+
}
|
|
914
|
+
else if (key === 'assetRefs' && Array.isArray(child)) {
|
|
915
|
+
for (const ref of child) {
|
|
916
|
+
if (typeof ref === 'string') {
|
|
917
|
+
refs.push(ref);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
visitByConvention(child);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
if (fields === undefined) {
|
|
927
|
+
visitByConvention(value);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
visitBySchema(value, fields);
|
|
931
|
+
}
|
|
932
|
+
return refs.sort(compareStrings);
|
|
933
|
+
}
|
|
934
|
+
function stableRecordKeys(record) {
|
|
935
|
+
return Reflect.ownKeys(record)
|
|
936
|
+
.filter((key) => typeof key === 'string')
|
|
937
|
+
.sort(compareStrings);
|
|
938
|
+
}
|
|
939
|
+
function reject(command, reason) {
|
|
940
|
+
return { commandId: command.id, reason };
|
|
941
|
+
}
|
|
942
|
+
function compareCommands(left, right) {
|
|
943
|
+
return left.frame - right.frame
|
|
944
|
+
|| left.slot - right.slot
|
|
945
|
+
|| compareStrings(left.channelId, right.channelId)
|
|
946
|
+
|| compareStrings(left.id, right.id);
|
|
947
|
+
}
|
|
948
|
+
function compareRejectedCommands(left, right) {
|
|
949
|
+
return compareStrings(left.commandId, right.commandId) || compareStrings(left.reason, right.reason);
|
|
950
|
+
}
|
|
951
|
+
function sortCommands(commands) {
|
|
952
|
+
return [...commands].sort(compareCommands);
|
|
953
|
+
}
|
|
954
|
+
function compareCommandsBySubmission(left, right) {
|
|
955
|
+
return left.submittedFrame - right.submittedFrame
|
|
956
|
+
|| compareCommands(left, right);
|
|
957
|
+
}
|
|
958
|
+
function validateCommandTimelineSnapshot(snapshot) {
|
|
959
|
+
const expected = createDeterministicCommandTimelineSnapshot({
|
|
960
|
+
currentFrame: snapshot.currentFrame,
|
|
961
|
+
accepted: snapshot.accepted,
|
|
962
|
+
rejected: snapshot.rejected,
|
|
963
|
+
});
|
|
964
|
+
if (expected.checksum !== snapshot.checksum) {
|
|
965
|
+
throw new Error('Deterministic command timeline snapshot checksum mismatch');
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function percentile(values, percentileRank) {
|
|
969
|
+
if (values.length === 0) {
|
|
970
|
+
return 0;
|
|
971
|
+
}
|
|
972
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
973
|
+
return sorted[Math.min(sorted.length - 1, Math.floor((percentileRank / 100) * sorted.length))];
|
|
974
|
+
}
|
|
975
|
+
function compareStrings(left, right) {
|
|
976
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
977
|
+
}
|