@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/schema.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { TickRate } from './types.js';
|
|
2
|
+
export type NumericMode = 'integer' | 'fixed-q16' | 'quantized-float' | 'render-only-float';
|
|
3
|
+
export type CollectionOrdering = 'indexed' | 'sorted-key' | 'stable-id';
|
|
4
|
+
export type InputFieldPolicy = 'repeat' | 'command';
|
|
5
|
+
export type SchemaFieldKind = 'boolean' | 'button' | 'integer' | 'fixed-q16' | 'string' | 'bitfield' | 'player-slot' | 'asset-ref';
|
|
6
|
+
export interface SchemaFieldDescriptor {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly kind: SchemaFieldKind;
|
|
9
|
+
readonly numericMode?: NumericMode;
|
|
10
|
+
readonly collectionOrdering?: CollectionOrdering;
|
|
11
|
+
readonly min?: number;
|
|
12
|
+
readonly max?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface InputFieldDescriptor extends SchemaFieldDescriptor {
|
|
15
|
+
readonly policy: InputFieldPolicy;
|
|
16
|
+
readonly defaultValue: boolean | number | string;
|
|
17
|
+
readonly bitPacking?: {
|
|
18
|
+
readonly bits: number;
|
|
19
|
+
readonly signed?: boolean;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface StateFieldDescriptor extends SchemaFieldDescriptor {
|
|
23
|
+
readonly checksummed: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface EventDescriptor {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly payloadFields: readonly SchemaFieldDescriptor[];
|
|
28
|
+
readonly rollback: 'predicted' | 'verified-only';
|
|
29
|
+
}
|
|
30
|
+
export interface CommandChannelDescriptor {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly fields: readonly SchemaFieldDescriptor[];
|
|
33
|
+
readonly delivery: 'reliable' | 'unreliable';
|
|
34
|
+
}
|
|
35
|
+
export interface AssetDescriptor {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly fields: readonly SchemaFieldDescriptor[];
|
|
38
|
+
}
|
|
39
|
+
export interface PrototypeDescriptor {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly components: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
export interface ConfigDescriptor {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly fields: readonly SchemaFieldDescriptor[];
|
|
46
|
+
}
|
|
47
|
+
export interface SystemDescriptor {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
}
|
|
50
|
+
export interface DeterministicGameDescriptor {
|
|
51
|
+
readonly deterministicVersion: string;
|
|
52
|
+
readonly tickRate: TickRate;
|
|
53
|
+
readonly maxPlayers: number;
|
|
54
|
+
readonly input: {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly fields: readonly InputFieldDescriptor[];
|
|
57
|
+
};
|
|
58
|
+
readonly state: {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly fields: readonly StateFieldDescriptor[];
|
|
61
|
+
};
|
|
62
|
+
readonly commandChannels?: readonly CommandChannelDescriptor[];
|
|
63
|
+
readonly events?: readonly EventDescriptor[];
|
|
64
|
+
readonly assets?: readonly AssetDescriptor[];
|
|
65
|
+
readonly prototypes?: readonly PrototypeDescriptor[];
|
|
66
|
+
readonly config?: ConfigDescriptor;
|
|
67
|
+
readonly systems: readonly SystemDescriptor[];
|
|
68
|
+
}
|
|
69
|
+
export interface ValidatedDeterministicGameDescriptor extends DeterministicGameDescriptor {
|
|
70
|
+
readonly artifactId: string;
|
|
71
|
+
readonly replayIdentity: {
|
|
72
|
+
readonly deterministicVersion: string;
|
|
73
|
+
readonly tickRate: TickRate;
|
|
74
|
+
readonly maxPlayers: number;
|
|
75
|
+
readonly inputSchemaId: string;
|
|
76
|
+
readonly stateSchemaId: string;
|
|
77
|
+
readonly commandSchemaId: string;
|
|
78
|
+
readonly eventSchemaId: string;
|
|
79
|
+
readonly assetSchemaId: string;
|
|
80
|
+
readonly prototypeSchemaId: string;
|
|
81
|
+
readonly configSchemaId: string;
|
|
82
|
+
readonly systemOrderId: string;
|
|
83
|
+
readonly artifactId: string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export declare function defineSyncplayGame(descriptor: DeterministicGameDescriptor): ValidatedDeterministicGameDescriptor;
|
|
87
|
+
export declare function stableDescriptorId(prefix: string, value: unknown): string;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { canonicalStringify, defaultChecksum } from './canonical.js';
|
|
2
|
+
import { SyncplayError } from './errors.js';
|
|
3
|
+
const tickRates = [10, 20, 30, 60];
|
|
4
|
+
const idPattern = /^[a-zA-Z0-9][a-zA-Z0-9._/@-]*$/;
|
|
5
|
+
const numericModes = ['integer', 'fixed-q16', 'quantized-float', 'render-only-float'];
|
|
6
|
+
const numericFieldKinds = ['integer', 'fixed-q16', 'bitfield', 'player-slot'];
|
|
7
|
+
const collectionOrderings = ['indexed', 'sorted-key', 'stable-id'];
|
|
8
|
+
export function defineSyncplayGame(descriptor) {
|
|
9
|
+
validateDescriptor(descriptor);
|
|
10
|
+
const eventSchemaId = descriptor.events === undefined ? 'events/none' : stableDescriptorId('events', descriptor.events);
|
|
11
|
+
const assetSchemaId = descriptor.assets === undefined ? 'assets/none' : stableDescriptorId('assets', descriptor.assets);
|
|
12
|
+
const commandSchemaId = descriptor.commandChannels === undefined ? 'commands/none' : stableDescriptorId('commands', descriptor.commandChannels);
|
|
13
|
+
const prototypeSchemaId = descriptor.prototypes === undefined ? 'prototypes/none' : stableDescriptorId('prototypes', descriptor.prototypes);
|
|
14
|
+
// Content-hash the input/state/config sub-ids like every other identity
|
|
15
|
+
// component: the author-declared label rides along, but editing the FIELDS
|
|
16
|
+
// without bumping the label must change the replay identity — otherwise a
|
|
17
|
+
// schema edit produces replays that verify as compatible and then diverge.
|
|
18
|
+
const configSchemaId = descriptor.config === undefined
|
|
19
|
+
? 'config/none'
|
|
20
|
+
: `${descriptor.config.id}#${stableDescriptorId('config', descriptor.config)}`;
|
|
21
|
+
const inputSchemaId = `${descriptor.input.id}#${stableDescriptorId('input', descriptor.input)}`;
|
|
22
|
+
const stateSchemaId = `${descriptor.state.id}#${stableDescriptorId('state', descriptor.state)}`;
|
|
23
|
+
const systemOrderId = stableDescriptorId('systems', descriptor.systems.map((system) => system.id));
|
|
24
|
+
const identity = {
|
|
25
|
+
deterministicVersion: descriptor.deterministicVersion,
|
|
26
|
+
tickRate: descriptor.tickRate,
|
|
27
|
+
maxPlayers: descriptor.maxPlayers,
|
|
28
|
+
inputSchemaId,
|
|
29
|
+
stateSchemaId,
|
|
30
|
+
commandSchemaId,
|
|
31
|
+
eventSchemaId,
|
|
32
|
+
assetSchemaId,
|
|
33
|
+
prototypeSchemaId,
|
|
34
|
+
configSchemaId,
|
|
35
|
+
systemOrderId,
|
|
36
|
+
artifactId: '',
|
|
37
|
+
};
|
|
38
|
+
const artifactId = stableDescriptorId('game', {
|
|
39
|
+
...descriptor,
|
|
40
|
+
commandSchemaId,
|
|
41
|
+
eventSchemaId,
|
|
42
|
+
assetSchemaId,
|
|
43
|
+
prototypeSchemaId,
|
|
44
|
+
configSchemaId,
|
|
45
|
+
systemOrderId,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
...descriptor,
|
|
49
|
+
artifactId,
|
|
50
|
+
replayIdentity: {
|
|
51
|
+
...identity,
|
|
52
|
+
artifactId,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function stableDescriptorId(prefix, value) {
|
|
57
|
+
return `${prefix}/${defaultChecksum(value)}`;
|
|
58
|
+
}
|
|
59
|
+
function validateDescriptor(descriptor) {
|
|
60
|
+
validateId('deterministicVersion', descriptor.deterministicVersion);
|
|
61
|
+
if (!tickRates.includes(descriptor.tickRate)) {
|
|
62
|
+
throwSchemaError('tickRate must be one of 10, 20, 30, or 60', 'schema.tick-rate');
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isInteger(descriptor.maxPlayers) || descriptor.maxPlayers < 1 || descriptor.maxPlayers > 128) {
|
|
65
|
+
throwSchemaError('maxPlayers must be an integer between 1 and 128', 'schema.max-players');
|
|
66
|
+
}
|
|
67
|
+
validateId('input.id', descriptor.input.id);
|
|
68
|
+
validateInputFields(descriptor.input.fields, descriptor.maxPlayers);
|
|
69
|
+
validateId('state.id', descriptor.state.id);
|
|
70
|
+
validateStateFields(descriptor.state.fields);
|
|
71
|
+
validateCommandChannels(descriptor.commandChannels ?? []);
|
|
72
|
+
validateEvents(descriptor.events ?? []);
|
|
73
|
+
validateAssets(descriptor.assets ?? []);
|
|
74
|
+
validatePrototypes(descriptor.prototypes ?? []);
|
|
75
|
+
if (descriptor.config !== undefined) {
|
|
76
|
+
validateId('config.id', descriptor.config.id);
|
|
77
|
+
validateFields('config.fields', descriptor.config.fields);
|
|
78
|
+
rejectButtonFields('config.fields', descriptor.config.fields);
|
|
79
|
+
}
|
|
80
|
+
validateNamedDescriptors('systems', descriptor.systems);
|
|
81
|
+
if (descriptor.systems.length === 0) {
|
|
82
|
+
throwSchemaError('At least one stateless system id is required', 'schema.systems-empty');
|
|
83
|
+
}
|
|
84
|
+
canonicalStringify(descriptor);
|
|
85
|
+
}
|
|
86
|
+
function validateCommandChannels(channels) {
|
|
87
|
+
validateNamedDescriptors('commandChannels', channels);
|
|
88
|
+
for (const channel of channels) {
|
|
89
|
+
if (channel.delivery !== 'reliable' && channel.delivery !== 'unreliable') {
|
|
90
|
+
throwSchemaError(`Command channel ${channel.id} has invalid delivery`, 'schema.command-delivery');
|
|
91
|
+
}
|
|
92
|
+
validateFields(`commandChannels.${channel.id}.fields`, channel.fields);
|
|
93
|
+
rejectButtonFields(`commandChannels.${channel.id}.fields`, channel.fields);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function validateEvents(events) {
|
|
97
|
+
validateNamedDescriptors('events', events);
|
|
98
|
+
for (const event of events) {
|
|
99
|
+
if (event.rollback !== 'predicted' && event.rollback !== 'verified-only') {
|
|
100
|
+
throwSchemaError(`Event ${event.id} has invalid rollback mode`, 'schema.event-rollback');
|
|
101
|
+
}
|
|
102
|
+
validateFields(`events.${event.id}.payloadFields`, event.payloadFields);
|
|
103
|
+
rejectButtonFields(`events.${event.id}.payloadFields`, event.payloadFields);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validateAssets(assets) {
|
|
107
|
+
validateNamedDescriptors('assets', assets);
|
|
108
|
+
for (const asset of assets) {
|
|
109
|
+
validateFields(`assets.${asset.id}.fields`, asset.fields);
|
|
110
|
+
rejectButtonFields(`assets.${asset.id}.fields`, asset.fields);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function validatePrototypes(prototypes) {
|
|
114
|
+
validateNamedDescriptors('prototypes', prototypes);
|
|
115
|
+
for (const prototype of prototypes) {
|
|
116
|
+
if (prototype.components.length === 0) {
|
|
117
|
+
throwSchemaError(`Prototype ${prototype.id} must include at least one component`, 'schema.prototype-components');
|
|
118
|
+
}
|
|
119
|
+
const components = new Set();
|
|
120
|
+
for (const component of prototype.components) {
|
|
121
|
+
validateId(`prototypes.${prototype.id}.component`, component);
|
|
122
|
+
if (components.has(component)) {
|
|
123
|
+
throwSchemaError(`Prototype ${prototype.id} contains duplicate component ${component}`, 'schema.prototype-duplicate-component');
|
|
124
|
+
}
|
|
125
|
+
components.add(component);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function validateInputFields(fields, maxPlayers) {
|
|
130
|
+
validateFields('input.fields', fields);
|
|
131
|
+
for (const field of fields) {
|
|
132
|
+
if (field.policy !== 'repeat' && field.policy !== 'command') {
|
|
133
|
+
throwSchemaError(`Input field ${field.name} has invalid policy`, 'schema.input-policy');
|
|
134
|
+
}
|
|
135
|
+
if (field.kind === 'bitfield'
|
|
136
|
+
&& (field.bitPacking === undefined
|
|
137
|
+
|| !Number.isInteger(field.bitPacking.bits)
|
|
138
|
+
|| field.bitPacking.bits < 1
|
|
139
|
+
|| field.bitPacking.bits > 32)) {
|
|
140
|
+
throwSchemaError(`Input bitfield ${field.name} requires 1 to 32 packing bits`, 'schema.bit-packing');
|
|
141
|
+
}
|
|
142
|
+
if (field.kind === 'button' && field.bitPacking !== undefined && (field.bitPacking.bits !== 1 || field.bitPacking.signed === true)) {
|
|
143
|
+
throwSchemaError(`Input button ${field.name} bitPacking must be one unsigned bit`, 'schema.bit-packing');
|
|
144
|
+
}
|
|
145
|
+
if (field.kind !== 'button'
|
|
146
|
+
&& field.kind !== 'bitfield'
|
|
147
|
+
&& field.kind !== 'integer'
|
|
148
|
+
&& field.kind !== 'player-slot'
|
|
149
|
+
&& field.bitPacking !== undefined) {
|
|
150
|
+
throwSchemaError(`Input ${field.name} bitPacking is only valid on numeric or button fields`, 'schema.bit-packing');
|
|
151
|
+
}
|
|
152
|
+
if (field.bitPacking !== undefined && typeof field.bitPacking.signed !== 'boolean' && field.bitPacking.signed !== undefined) {
|
|
153
|
+
throwSchemaError(`Input bitfield ${field.name} signed packing flag must be boolean`, 'schema.bit-packing');
|
|
154
|
+
}
|
|
155
|
+
validateInputDefaultValue(field);
|
|
156
|
+
const playerSlotDefault = Number(field.defaultValue);
|
|
157
|
+
// Validate against the game's actual maxPlayers, not the platform cap of
|
|
158
|
+
// 128 — the runtime canonicalizer rejects `value >= maxPlayers`
|
|
159
|
+
// (schema-artifacts.player-slot), so a default that only passes the 128
|
|
160
|
+
// check here would throw in the hot path the first time it is substituted.
|
|
161
|
+
if (field.kind === 'player-slot' && (playerSlotDefault < 0 || playerSlotDefault >= maxPlayers)) {
|
|
162
|
+
throwSchemaError(`Input player-slot ${field.name} defaultValue must be within [0, maxPlayers)`, 'schema.input-default');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function validateStateFields(fields) {
|
|
167
|
+
validateFields('state.fields', fields);
|
|
168
|
+
for (const field of fields) {
|
|
169
|
+
if (field.checksummed !== true && field.checksummed !== false) {
|
|
170
|
+
throwSchemaError(`State field ${field.name} must declare checksum participation`, 'schema.state-checksum');
|
|
171
|
+
}
|
|
172
|
+
if (field.numericMode === 'render-only-float' && field.checksummed) {
|
|
173
|
+
throwSchemaError(`Render-only float field ${field.name} cannot be checksummed`, 'schema.render-float-checksum');
|
|
174
|
+
}
|
|
175
|
+
if (field.kind === 'button') {
|
|
176
|
+
throwSchemaError(`State field ${field.name} cannot use input-only button kind`, 'schema.button-state');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// With zero checksummed fields the state projection is {} for every frame,
|
|
180
|
+
// so checksums are a single constant — desync detection and replay
|
|
181
|
+
// verification would silently compare always-equal values. Reject at
|
|
182
|
+
// definition time (after per-field validation, so specific field errors
|
|
183
|
+
// surface first): a determinism contract needs at least one observed field.
|
|
184
|
+
if (!fields.some((field) => field.checksummed === true)) {
|
|
185
|
+
throwSchemaError('At least one state field must be checksummed', 'schema.state-no-checksum');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function rejectButtonFields(label, fields) {
|
|
189
|
+
for (const field of fields) {
|
|
190
|
+
if (field.kind === 'button') {
|
|
191
|
+
throwSchemaError(`${label}.${field.name} cannot use input-only button kind`, 'schema.button-non-input');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function validateFields(label, fields) {
|
|
196
|
+
if (fields.length === 0) {
|
|
197
|
+
throwSchemaError(`${label} must not be empty`, 'schema.empty-fields');
|
|
198
|
+
}
|
|
199
|
+
const names = new Set();
|
|
200
|
+
for (const field of fields) {
|
|
201
|
+
validateId(`${label}.name`, field.name);
|
|
202
|
+
if (names.has(field.name)) {
|
|
203
|
+
throwSchemaError(`${label} contains duplicate field ${field.name}`, 'schema.duplicate-field');
|
|
204
|
+
}
|
|
205
|
+
names.add(field.name);
|
|
206
|
+
validateFieldKind(field);
|
|
207
|
+
validateNumericMode(field);
|
|
208
|
+
validateCollectionOrdering(field);
|
|
209
|
+
validateRange(field);
|
|
210
|
+
if (field.min !== undefined && field.max !== undefined && field.max < field.min) {
|
|
211
|
+
throwSchemaError(`Field ${field.name} has max below min`, 'schema.invalid-range');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function validateRange(field) {
|
|
216
|
+
if (field.min !== undefined && (typeof field.min !== 'number' || !Number.isFinite(field.min))) {
|
|
217
|
+
throwSchemaError(`Field ${field.name} min must be numeric`, 'schema.invalid-range');
|
|
218
|
+
}
|
|
219
|
+
if (field.max !== undefined && (typeof field.max !== 'number' || !Number.isFinite(field.max))) {
|
|
220
|
+
throwSchemaError(`Field ${field.name} max must be numeric`, 'schema.invalid-range');
|
|
221
|
+
}
|
|
222
|
+
if ((field.min !== undefined || field.max !== undefined) && !numericFieldKinds.includes(field.kind)) {
|
|
223
|
+
throwSchemaError(`Field ${field.name} range is only valid on numeric fields`, 'schema.invalid-range');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function validateNumericMode(field) {
|
|
227
|
+
if (field.numericMode === undefined) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!numericModes.includes(field.numericMode)) {
|
|
231
|
+
throwSchemaError(`Field ${field.name} has invalid numericMode`, 'schema.numeric-mode');
|
|
232
|
+
}
|
|
233
|
+
if (!numericFieldKinds.includes(field.kind)) {
|
|
234
|
+
throwSchemaError(`Field ${field.name} numericMode is only valid on numeric fields`, 'schema.numeric-mode');
|
|
235
|
+
}
|
|
236
|
+
if (field.kind === 'fixed-q16' && field.numericMode !== 'fixed-q16' && field.numericMode !== 'quantized-float' && field.numericMode !== 'render-only-float') {
|
|
237
|
+
throwSchemaError(`Field ${field.name} fixed-q16 requires fixed numericMode`, 'schema.numeric-mode');
|
|
238
|
+
}
|
|
239
|
+
if ((field.kind === 'integer' || field.kind === 'bitfield' || field.kind === 'player-slot') && field.numericMode !== 'integer') {
|
|
240
|
+
throwSchemaError(`Field ${field.name} integer field requires integer numericMode`, 'schema.numeric-mode');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function validateCollectionOrdering(field) {
|
|
244
|
+
if (field.collectionOrdering === undefined) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!collectionOrderings.includes(field.collectionOrdering)) {
|
|
248
|
+
throwSchemaError(`Field ${field.name} has invalid collectionOrdering`, 'schema.collection-ordering');
|
|
249
|
+
}
|
|
250
|
+
// Scalar collection fields have no record ids to sort by — accepting a
|
|
251
|
+
// contract the canonicalizer cannot enforce is worse than rejecting it.
|
|
252
|
+
// Reintroduce alongside record-item collections.
|
|
253
|
+
if (field.collectionOrdering === 'stable-id') {
|
|
254
|
+
throwSchemaError(`Field ${field.name} collectionOrdering 'stable-id' is not supported on scalar collections (use 'indexed' or 'sorted-key')`, 'schema.collection-ordering');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function validateFieldKind(field) {
|
|
258
|
+
switch (field.kind) {
|
|
259
|
+
case 'boolean':
|
|
260
|
+
case 'button':
|
|
261
|
+
case 'integer':
|
|
262
|
+
case 'fixed-q16':
|
|
263
|
+
case 'string':
|
|
264
|
+
case 'bitfield':
|
|
265
|
+
case 'player-slot':
|
|
266
|
+
case 'asset-ref':
|
|
267
|
+
return;
|
|
268
|
+
default:
|
|
269
|
+
throwSchemaError(`Unsupported field kind ${field.kind}`, 'schema.field-kind');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function validateInputDefaultValue(field) {
|
|
273
|
+
if (field.kind === 'boolean' || field.kind === 'button') {
|
|
274
|
+
if (typeof field.defaultValue !== 'boolean') {
|
|
275
|
+
throwSchemaError(`Input ${field.kind} ${field.name} requires boolean defaultValue`, 'schema.input-default');
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (field.kind === 'integer' || field.kind === 'bitfield' || field.kind === 'player-slot') {
|
|
280
|
+
if (typeof field.defaultValue !== 'number' || !Number.isInteger(field.defaultValue)) {
|
|
281
|
+
throwSchemaError(`Input ${field.kind} ${field.name} requires integer defaultValue`, 'schema.input-default');
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (field.kind === 'fixed-q16') {
|
|
286
|
+
if (typeof field.defaultValue !== 'number' || !Number.isInteger(field.defaultValue)) {
|
|
287
|
+
throwSchemaError(`Input fixed-q16 ${field.name} requires quantized integer defaultValue`, 'schema.input-default');
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
/* istanbul ignore else -- validateFieldKind rejects unsupported input field kinds before default validation. */
|
|
292
|
+
if (field.kind === 'string' || field.kind === 'asset-ref') {
|
|
293
|
+
if (typeof field.defaultValue !== 'string') {
|
|
294
|
+
throwSchemaError(`Input ${field.kind} ${field.name} requires string defaultValue`, 'schema.input-default');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function validateNamedDescriptors(label, descriptors) {
|
|
299
|
+
const ids = new Set();
|
|
300
|
+
for (const descriptor of descriptors) {
|
|
301
|
+
validateId(`${label}.id`, descriptor.id);
|
|
302
|
+
if (ids.has(descriptor.id)) {
|
|
303
|
+
throwSchemaError(`${label} contains duplicate id ${descriptor.id}`, 'schema.duplicate-id');
|
|
304
|
+
}
|
|
305
|
+
ids.add(descriptor.id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function validateId(label, value) {
|
|
309
|
+
if (typeof value !== 'string' || value.length === 0 || !idPattern.test(value)) {
|
|
310
|
+
throwSchemaError(`${label} must be a stable non-empty id`, 'schema.invalid-id');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function throwSchemaError(message, code) {
|
|
314
|
+
throw new SyncplayError(message, { code });
|
|
315
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DeterministicGameDescriptor } from './schema.js';
|
|
2
|
+
import type { DeterministicStep, OfflineSession, ReplayFile, ReplayRecordingMode } from './types.js';
|
|
3
|
+
export interface DeterministicOfflineRuntimeConfig<State, Input> {
|
|
4
|
+
readonly descriptor: DeterministicGameDescriptor;
|
|
5
|
+
readonly playerCount?: number;
|
|
6
|
+
readonly initialState: State;
|
|
7
|
+
readonly defaultInput: Input;
|
|
8
|
+
readonly step: DeterministicStep<State, Input>;
|
|
9
|
+
readonly randomSeed?: number;
|
|
10
|
+
readonly snapshotBufferSize?: number;
|
|
11
|
+
readonly checksumIntervalFrames?: number;
|
|
12
|
+
readonly maxCatchUpFrames?: number;
|
|
13
|
+
readonly replayRecordingMode?: ReplayRecordingMode;
|
|
14
|
+
readonly runtimeGuards?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface DeterministicOfflineFrame<State> {
|
|
17
|
+
readonly mode: 'offline';
|
|
18
|
+
readonly frame: number;
|
|
19
|
+
readonly predictedFrame: number;
|
|
20
|
+
readonly verifiedFrame: number;
|
|
21
|
+
readonly state: State;
|
|
22
|
+
readonly checksum: string;
|
|
23
|
+
}
|
|
24
|
+
export interface DeterministicOfflineRuntime<State, Input> {
|
|
25
|
+
readonly mode: 'offline';
|
|
26
|
+
readonly session: OfflineSession<State, Input>;
|
|
27
|
+
getFrame(): DeterministicOfflineFrame<State>;
|
|
28
|
+
setInput(slot: number, input: Input): void;
|
|
29
|
+
update(deltaMs: number): number;
|
|
30
|
+
stepFrames(count: number): void;
|
|
31
|
+
subscribe(listener: (frame: DeterministicOfflineFrame<State>) => void): () => void;
|
|
32
|
+
exportReplay(): ReplayFile;
|
|
33
|
+
}
|
|
34
|
+
export declare function createSyncplayOfflineRuntime<State, Input>(config: DeterministicOfflineRuntimeConfig<State, Input>): DeterministicOfflineRuntime<State, Input>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createOfflineSession } from './offline-session.js';
|
|
2
|
+
import { createSchemaArtifacts } from './schema-artifacts.js';
|
|
3
|
+
export function createSyncplayOfflineRuntime(config) {
|
|
4
|
+
const artifacts = createSchemaArtifacts(config.descriptor);
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
const playerCount = config.playerCount ?? config.descriptor.maxPlayers;
|
|
7
|
+
if (playerCount > config.descriptor.maxPlayers) {
|
|
8
|
+
throw new Error(`Offline runtime playerCount ${playerCount} exceeds descriptor maxPlayers ${config.descriptor.maxPlayers}`);
|
|
9
|
+
}
|
|
10
|
+
const session = createOfflineSession({
|
|
11
|
+
tickRate: config.descriptor.tickRate,
|
|
12
|
+
playerCount,
|
|
13
|
+
initialState: config.initialState,
|
|
14
|
+
step: config.step,
|
|
15
|
+
defaultInput: config.defaultInput,
|
|
16
|
+
// Use the content-hashed identity ids, not the author labels — replay
|
|
17
|
+
// compatibility must track the actual field shapes.
|
|
18
|
+
inputSchemaId: artifacts.descriptor.replayIdentity.inputSchemaId,
|
|
19
|
+
stateSchemaId: artifacts.descriptor.replayIdentity.stateSchemaId,
|
|
20
|
+
deterministicVersion: config.descriptor.deterministicVersion,
|
|
21
|
+
canonicalizeInput(input) {
|
|
22
|
+
return artifacts.canonicalizeInput(input);
|
|
23
|
+
},
|
|
24
|
+
// FULL declared state for replay initial-state and snapshots: the session
|
|
25
|
+
// RESTORES from these (rollback, late-join hydration), so they must carry
|
|
26
|
+
// every declared field. The checksum projection stays checksum-only below —
|
|
27
|
+
// wiring it here wiped every checksummed:false field to undefined on the
|
|
28
|
+
// first rollback, silently diverging from peers that never rolled back.
|
|
29
|
+
canonicalizeState(state) {
|
|
30
|
+
return artifacts.canonicalizeFullState(state);
|
|
31
|
+
},
|
|
32
|
+
serializeState(state) {
|
|
33
|
+
return artifacts.serializeFullState(state);
|
|
34
|
+
},
|
|
35
|
+
deserializeState(bytes) {
|
|
36
|
+
return artifacts.deserializeFullState(bytes);
|
|
37
|
+
},
|
|
38
|
+
checksum(state) {
|
|
39
|
+
return artifacts.checksumState(state);
|
|
40
|
+
},
|
|
41
|
+
randomSeed: config.randomSeed,
|
|
42
|
+
snapshotBufferSize: config.snapshotBufferSize,
|
|
43
|
+
checksumIntervalFrames: config.checksumIntervalFrames,
|
|
44
|
+
maxCatchUpFrames: config.maxCatchUpFrames,
|
|
45
|
+
replayRecordingMode: config.replayRecordingMode,
|
|
46
|
+
runtimeGuards: config.runtimeGuards,
|
|
47
|
+
});
|
|
48
|
+
const runtime = {
|
|
49
|
+
mode: 'offline',
|
|
50
|
+
session,
|
|
51
|
+
getFrame() {
|
|
52
|
+
const state = session.getState();
|
|
53
|
+
return {
|
|
54
|
+
mode: 'offline',
|
|
55
|
+
frame: session.currentFrame,
|
|
56
|
+
predictedFrame: session.currentFrame,
|
|
57
|
+
verifiedFrame: session.currentFrame,
|
|
58
|
+
state,
|
|
59
|
+
checksum: artifacts.checksumState(state),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
setInput(slot, input) {
|
|
63
|
+
session.setInput(slot, input);
|
|
64
|
+
},
|
|
65
|
+
update(deltaMs) {
|
|
66
|
+
const advanced = session.update(deltaMs);
|
|
67
|
+
if (advanced > 0) {
|
|
68
|
+
notify();
|
|
69
|
+
}
|
|
70
|
+
return advanced;
|
|
71
|
+
},
|
|
72
|
+
stepFrames(count) {
|
|
73
|
+
session.stepFrames(count);
|
|
74
|
+
if (count > 0) {
|
|
75
|
+
notify();
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
subscribe(listener) {
|
|
79
|
+
listeners.add(listener);
|
|
80
|
+
listener(runtime.getFrame());
|
|
81
|
+
return () => {
|
|
82
|
+
listeners.delete(listener);
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
exportReplay() {
|
|
86
|
+
return session.exportReplay();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
function notify() {
|
|
90
|
+
const frame = runtime.getFrame();
|
|
91
|
+
for (const listener of listeners) {
|
|
92
|
+
listener(frame);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return runtime;
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DeterministicSessionConfig, OfflineSession } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Start-from-snapshot session promotion (practice → online, Quantum 3.0.5
|
|
4
|
+
* parity): freeze a running offline session into a self-describing blob, then
|
|
5
|
+
* hydrate a FRESH session from it with frame numbering continuing where the
|
|
6
|
+
* source left off.
|
|
7
|
+
*
|
|
8
|
+
* The blob is the same versioned `DeterministicStateSnapshot` envelope
|
|
9
|
+
* (`encodeDeterministicStateSnapshot`, `v: 1`) that the snapshot late-join
|
|
10
|
+
* transfer path ships, so it is wire-compatible with the existing
|
|
11
|
+
* snapshot-offer/chunk/complete envelopes.
|
|
12
|
+
*
|
|
13
|
+
* HONESTY NOTE — the online half is protocol work that is NOT done here.
|
|
14
|
+
* Seeding a NETWORKED match from a promoted snapshot requires the authority to
|
|
15
|
+
* accept an initial snapshot at session start (today the authority only brokers
|
|
16
|
+
* snapshots for late-joiners into an already-running match, and networked
|
|
17
|
+
* clients always start from tick 0 of the room descriptor). Until that
|
|
18
|
+
* protocol lands, promotion works offline-to-offline: practice session →
|
|
19
|
+
* `exportPromotionSnapshot` → `createSessionFromPromotionSnapshot` on any peer
|
|
20
|
+
* running the same game build.
|
|
21
|
+
*
|
|
22
|
+
* The hydrated session cannot export a replay (its history does not start at
|
|
23
|
+
* tick 0) — `exportReplay()` throws, per the `restoreSnapshot` contract.
|
|
24
|
+
*/
|
|
25
|
+
export declare function exportPromotionSnapshot<State, Input>(session: OfflineSession<State, Input>, frame?: number): string;
|
|
26
|
+
export declare function createSessionFromPromotionSnapshot<State, Input>(config: DeterministicSessionConfig<State, Input>, blob: string): OfflineSession<State, Input>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { canonicalize } from './canonical.js';
|
|
2
|
+
import { SyncplayError, invariant } from './errors.js';
|
|
3
|
+
import { createOfflineSession } from './offline-session.js';
|
|
4
|
+
import { base64ToBytes, bytesToBase64, encodeDeterministicStateSnapshot, decodeDeterministicStateSnapshot, } from './session-snapshot.js';
|
|
5
|
+
/**
|
|
6
|
+
* Start-from-snapshot session promotion (practice → online, Quantum 3.0.5
|
|
7
|
+
* parity): freeze a running offline session into a self-describing blob, then
|
|
8
|
+
* hydrate a FRESH session from it with frame numbering continuing where the
|
|
9
|
+
* source left off.
|
|
10
|
+
*
|
|
11
|
+
* The blob is the same versioned `DeterministicStateSnapshot` envelope
|
|
12
|
+
* (`encodeDeterministicStateSnapshot`, `v: 1`) that the snapshot late-join
|
|
13
|
+
* transfer path ships, so it is wire-compatible with the existing
|
|
14
|
+
* snapshot-offer/chunk/complete envelopes.
|
|
15
|
+
*
|
|
16
|
+
* HONESTY NOTE — the online half is protocol work that is NOT done here.
|
|
17
|
+
* Seeding a NETWORKED match from a promoted snapshot requires the authority to
|
|
18
|
+
* accept an initial snapshot at session start (today the authority only brokers
|
|
19
|
+
* snapshots for late-joiners into an already-running match, and networked
|
|
20
|
+
* clients always start from tick 0 of the room descriptor). Until that
|
|
21
|
+
* protocol lands, promotion works offline-to-offline: practice session →
|
|
22
|
+
* `exportPromotionSnapshot` → `createSessionFromPromotionSnapshot` on any peer
|
|
23
|
+
* running the same game build.
|
|
24
|
+
*
|
|
25
|
+
* The hydrated session cannot export a replay (its history does not start at
|
|
26
|
+
* tick 0) — `exportReplay()` throws, per the `restoreSnapshot` contract.
|
|
27
|
+
*/
|
|
28
|
+
export function exportPromotionSnapshot(session, frame) {
|
|
29
|
+
const targetFrame = frame ?? session.currentFrame;
|
|
30
|
+
invariant(Number.isInteger(targetFrame) && targetFrame >= 1, 'Promotion export requires a stepped frame (>= 1): the v1 snapshot envelope encodes tick = frame - 1', 'promotion.invalid-frame');
|
|
31
|
+
const snapshot = session.getSnapshot(targetFrame);
|
|
32
|
+
if (snapshot === undefined) {
|
|
33
|
+
throw new SyncplayError(`No snapshot buffered for frame ${targetFrame} — it may have been evicted from the rolling buffer`, { code: 'promotion.missing-snapshot', frame: targetFrame });
|
|
34
|
+
}
|
|
35
|
+
return encodeDeterministicStateSnapshot({
|
|
36
|
+
v: 1,
|
|
37
|
+
tick: targetFrame - 1,
|
|
38
|
+
sessionFrame: targetFrame,
|
|
39
|
+
stateBytes: bytesToBase64(snapshot.stateBytes),
|
|
40
|
+
randomState: snapshot.randomState,
|
|
41
|
+
stateChecksum: snapshot.checksum,
|
|
42
|
+
// Session inputs are canonical by construction (cloneCanonical on setInput).
|
|
43
|
+
confirmedInputs: snapshot.inputs.map((input) => canonicalize(input)),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function createSessionFromPromotionSnapshot(config, blob) {
|
|
47
|
+
const decoded = decodeDeterministicStateSnapshot(blob);
|
|
48
|
+
if (!decoded.ok) {
|
|
49
|
+
throw new SyncplayError(`Promotion snapshot rejected: ${decoded.reason}`, {
|
|
50
|
+
code: 'promotion.invalid-snapshot',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const snapshot = decoded.snapshot;
|
|
54
|
+
invariant(snapshot.confirmedInputs.length === config.playerCount, `Promotion snapshot carries ${snapshot.confirmedInputs.length} input slots but config.playerCount is ${config.playerCount}`, 'promotion.player-count-mismatch');
|
|
55
|
+
const session = createOfflineSession(config);
|
|
56
|
+
session.restoreSnapshot({
|
|
57
|
+
frame: snapshot.sessionFrame,
|
|
58
|
+
stateBytes: base64ToBytes(snapshot.stateBytes),
|
|
59
|
+
randomState: snapshot.randomState,
|
|
60
|
+
inputs: snapshot.confirmedInputs.map(
|
|
61
|
+
// NOT `??`: a hydrator returning null (the canonical neutral) must win
|
|
62
|
+
// over the raw recorded value (adversarial-review LOW).
|
|
63
|
+
(recorded) => (config.hydrateInput !== undefined ? config.hydrateInput(recorded) : recorded)),
|
|
64
|
+
});
|
|
65
|
+
const restored = session.getSnapshot(session.currentFrame);
|
|
66
|
+
if (restored === undefined || restored.checksum !== snapshot.stateChecksum) {
|
|
67
|
+
throw new SyncplayError('Promotion snapshot checksum mismatch after hydration — the game build or state bytes do not match the exporter', { code: 'promotion.checksum-mismatch', frame: snapshot.sessionFrame });
|
|
68
|
+
}
|
|
69
|
+
return session;
|
|
70
|
+
}
|