@series-inc/rundot-syncplay 5.25.0-beta.1 → 5.25.0-beta.3
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 +27 -0
- package/dist/browser.d.ts +5 -0
- package/dist/browser.js +6 -0
- package/dist/coarse-region.d.ts +20 -0
- package/dist/coarse-region.js +87 -0
- package/dist/dev-authority-room.d.ts +14 -0
- package/dist/dev-authority-room.js +227 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +3 -0
- package/dist/run-room-transport.d.ts +100 -0
- package/dist/run-room-transport.js +280 -0
- package/dist/runtime-session.d.ts +24 -4
- package/dist/runtime-session.js +42 -1
- package/dist/sdk-session.d.ts +4 -5
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/vite-plugin.d.ts +40 -0
- package/dist/tools/vite-plugin.js +100 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -52,6 +52,33 @@ session-config bytes, asks the game for a matching runtime through its runtime
|
|
|
52
52
|
factory, and rejects a locally loaded pack mismatch before frame 0. Replays use
|
|
53
53
|
the same identity/config/factory path.
|
|
54
54
|
|
|
55
|
+
## Presentation frames
|
|
56
|
+
|
|
57
|
+
`session.getState()` returns a detached deep copy of the current frame, which
|
|
58
|
+
costs O(state) per read and is unaffordable for a large mutable world (a
|
|
59
|
+
destructible voxel city, a big streaming map).
|
|
60
|
+
|
|
61
|
+
`session.getPresentationFrame()` is the no-clone path. It calls the runtime's
|
|
62
|
+
optional `project(checkpoint, previous)` and returns the result untouched:
|
|
63
|
+
|
|
64
|
+
- **Requires** a runtime built with a `projectState` hook. A runtime without
|
|
65
|
+
`project` throws `SYNCPLAY_PROJECTION_UNSUPPORTED` — there is no silent
|
|
66
|
+
fallback to `inspect`.
|
|
67
|
+
- **The result is immutable.** Callers must not mutate it. It may share structure
|
|
68
|
+
with authoritative state and with previously returned projections, which is
|
|
69
|
+
exactly what lets a renderer diff persistent roots and skip shared subtrees.
|
|
70
|
+
- **`previous` is the last frame this session projected**, enabling delta
|
|
71
|
+
projections. It is absent — meaning the runtime should produce a full frame —
|
|
72
|
+
on the first call, after `restoreToFrame` or `restoreSnapshot`, and whenever
|
|
73
|
+
the previously projected frame is no longer retained (pruned or rolled past).
|
|
74
|
+
- **Reading the same frame twice is free and idempotent.** Repeat calls return
|
|
75
|
+
the identical projection rather than re-projecting the frame against itself
|
|
76
|
+
(which would report an empty delta). Authoritative state cannot change without
|
|
77
|
+
the frame advancing, so multiple render passes per frame are safe.
|
|
78
|
+
|
|
79
|
+
Projections are presentation-only: they never re-enter simulation and are not
|
|
80
|
+
part of deterministic identity.
|
|
81
|
+
|
|
55
82
|
## Public projections and secrets
|
|
56
83
|
|
|
57
84
|
Every peer applies the same confirmed inputs and authority commands. Optional
|
package/dist/browser.d.ts
CHANGED
|
@@ -82,3 +82,8 @@ export { createSyncplaySecretEventReader, createSyncplaySecretsClient } from './
|
|
|
82
82
|
export type { SyncplaySecretSystemDefinition, SyncplaySecretSystemsConfig } from './secret-config.js';
|
|
83
83
|
export type { SyncplayOwnedSecretToken, SyncplaySecretCommand, SyncplaySecretErrorCode, SyncplaySecretPrivateMessage, SyncplaySignedReceipt } from './secret-protocol.js';
|
|
84
84
|
export type { SyncplayChoiceClient, SyncplayChoiceResolvedEvent, SyncplayCollectionClient, SyncplayCollectionRevealedEvent, SyncplayOwnedSecret, SyncplayRandomClient, SyncplayRandomDrawnEvent, SyncplayRoleClient, SyncplayRoleRevealedEvent, SyncplaySecretEventReader, SyncplaySecretsClient } from './secret-client.js';
|
|
85
|
+
export * from './run-room-transport.js';
|
|
86
|
+
export { deriveCoarseRegion, detectCoarseRegion } from './coarse-region.js';
|
|
87
|
+
export type { CoarseRegion } from './coarse-region.js';
|
|
88
|
+
export { WsMultiplayerApi } from '@series-inc/rundot-game-sdk/mp-client';
|
|
89
|
+
export type { MultiplayerApi, JoinTicketRequest } from '@series-inc/rundot-game-sdk/mp-client';
|
package/dist/browser.js
CHANGED
|
@@ -42,3 +42,9 @@ export { syncplaySecretConfigHash, syncplaySecretLimits, validateSyncplaySecretS
|
|
|
42
42
|
export { decodeSyncplaySecretPrivateMessage, isSyncplaySecretCommand, syncplaySecretProtocol } from './secret-protocol.js';
|
|
43
43
|
export { createBrowserSyncplaySecretCrypto, verifySyncplaySignedReceipt } from './secret-crypto.js';
|
|
44
44
|
export { createSyncplaySecretEventReader, createSyncplaySecretsClient } from './secret-client.js';
|
|
45
|
+
// RUN-platform multiplayer glue (relocated from the game SDK — see the dep
|
|
46
|
+
// inversion). The room client/transport + coarse-region matchmaking helpers a
|
|
47
|
+
// game hands to `createNetworkedSyncplayClient`.
|
|
48
|
+
export * from './run-room-transport.js';
|
|
49
|
+
export { deriveCoarseRegion, detectCoarseRegion } from './coarse-region.js';
|
|
50
|
+
export { WsMultiplayerApi } from '@series-inc/rundot-game-sdk/mp-client';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coarse geographic region derived from the device's IANA timezone (G6:
|
|
3
|
+
* region-aware quick-match). Matchmaking pools pair on strict criteria
|
|
4
|
+
* equality, so a `region` criterion IS a region-aware pool key — no server
|
|
5
|
+
* schema change. Deliberately coarse (continent-scale): fine-grained buckets
|
|
6
|
+
* fragment thin player pools, and the goal is only to avoid cross-ocean
|
|
7
|
+
* pairings whose RTT forces constant deep rollback.
|
|
8
|
+
*
|
|
9
|
+
* Pure timezone-name mapping: no network calls, no clock reads.
|
|
10
|
+
*/
|
|
11
|
+
export type CoarseRegion = 'na' | 'sa' | 'eu' | 'africa' | 'asia' | 'oceania' | 'unknown';
|
|
12
|
+
/** Pure mapping from an IANA timezone name to a coarse region. */
|
|
13
|
+
export declare function deriveCoarseRegion(timeZone: string): CoarseRegion;
|
|
14
|
+
/**
|
|
15
|
+
* Coarse region of THIS device, from `Intl.DateTimeFormat` timezone
|
|
16
|
+
* resolution. 'unknown' when the runtime resolves no zone (or an
|
|
17
|
+
* unclassifiable one like Etc/UTC) — callers should then skip region
|
|
18
|
+
* tagging rather than pool players into an 'unknown' bucket.
|
|
19
|
+
*/
|
|
20
|
+
export declare function detectCoarseRegion(): CoarseRegion;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coarse geographic region derived from the device's IANA timezone (G6:
|
|
3
|
+
* region-aware quick-match). Matchmaking pools pair on strict criteria
|
|
4
|
+
* equality, so a `region` criterion IS a region-aware pool key — no server
|
|
5
|
+
* schema change. Deliberately coarse (continent-scale): fine-grained buckets
|
|
6
|
+
* fragment thin player pools, and the goal is only to avoid cross-ocean
|
|
7
|
+
* pairings whose RTT forces constant deep rollback.
|
|
8
|
+
*
|
|
9
|
+
* Pure timezone-name mapping: no network calls, no clock reads.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* America/* zones that are South America. Everything else under America/
|
|
13
|
+
* (including Central America and the Caribbean) stays 'na' — coarse on
|
|
14
|
+
* purpose. Covers the second path segment, so both `America/Sao_Paulo` and
|
|
15
|
+
* `America/Argentina/Buenos_Aires` resolve via 'Sao_Paulo' / 'Argentina'.
|
|
16
|
+
*/
|
|
17
|
+
const SOUTH_AMERICA_SEGMENTS = new Set([
|
|
18
|
+
'Araguaina',
|
|
19
|
+
'Argentina',
|
|
20
|
+
'Asuncion',
|
|
21
|
+
'Bahia',
|
|
22
|
+
'Belem',
|
|
23
|
+
'Boa_Vista',
|
|
24
|
+
'Bogota',
|
|
25
|
+
'Buenos_Aires',
|
|
26
|
+
'Campo_Grande',
|
|
27
|
+
'Catamarca',
|
|
28
|
+
'Cayenne',
|
|
29
|
+
'Cordoba',
|
|
30
|
+
'Cuiaba',
|
|
31
|
+
'Eirunepe',
|
|
32
|
+
'Fortaleza',
|
|
33
|
+
'Guayaquil',
|
|
34
|
+
'Guyana',
|
|
35
|
+
'Jujuy',
|
|
36
|
+
'La_Paz',
|
|
37
|
+
'Lima',
|
|
38
|
+
'Maceio',
|
|
39
|
+
'Manaus',
|
|
40
|
+
'Mendoza',
|
|
41
|
+
'Montevideo',
|
|
42
|
+
'Noronha',
|
|
43
|
+
'Paramaribo',
|
|
44
|
+
'Porto_Velho',
|
|
45
|
+
'Punta_Arenas',
|
|
46
|
+
'Recife',
|
|
47
|
+
'Rio_Branco',
|
|
48
|
+
'Rosario',
|
|
49
|
+
'Santarem',
|
|
50
|
+
'Santiago',
|
|
51
|
+
'Sao_Paulo',
|
|
52
|
+
]);
|
|
53
|
+
const AREA_REGIONS = {
|
|
54
|
+
Africa: 'africa',
|
|
55
|
+
// Arctic/Longyearbyen is the area's only zone (Svalbard, Norway).
|
|
56
|
+
Arctic: 'eu',
|
|
57
|
+
Asia: 'asia',
|
|
58
|
+
// Mostly Europe-adjacent islands (Azores, Canary, Reykjavik, Faroe...).
|
|
59
|
+
Atlantic: 'eu',
|
|
60
|
+
Australia: 'oceania',
|
|
61
|
+
Europe: 'eu',
|
|
62
|
+
// Mostly African-coast islands (Mauritius, Reunion, Antananarivo...).
|
|
63
|
+
Indian: 'africa',
|
|
64
|
+
Pacific: 'oceania',
|
|
65
|
+
};
|
|
66
|
+
/** Pure mapping from an IANA timezone name to a coarse region. */
|
|
67
|
+
export function deriveCoarseRegion(timeZone) {
|
|
68
|
+
const [area, subArea] = timeZone.split('/');
|
|
69
|
+
if (area === 'America' && subArea !== undefined) {
|
|
70
|
+
return SOUTH_AMERICA_SEGMENTS.has(subArea) ? 'sa' : 'na';
|
|
71
|
+
}
|
|
72
|
+
return AREA_REGIONS[area] ?? 'unknown';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Coarse region of THIS device, from `Intl.DateTimeFormat` timezone
|
|
76
|
+
* resolution. 'unknown' when the runtime resolves no zone (or an
|
|
77
|
+
* unclassifiable one like Etc/UTC) — callers should then skip region
|
|
78
|
+
* tagging rather than pool players into an 'unknown' bucket.
|
|
79
|
+
*/
|
|
80
|
+
export function detectCoarseRegion() {
|
|
81
|
+
try {
|
|
82
|
+
return deriveCoarseRegion(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return 'unknown';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Logger, RoomProtocol } from '@series-inc/rundot-game-sdk/mp-server';
|
|
2
|
+
interface DevSyncplayRoomContext {
|
|
3
|
+
protocol: RoomProtocol;
|
|
4
|
+
roomId: string;
|
|
5
|
+
config: Readonly<Record<string, unknown>>;
|
|
6
|
+
log: Logger;
|
|
7
|
+
}
|
|
8
|
+
export declare class DevSyncplayAuthorityRoom {
|
|
9
|
+
private readonly players;
|
|
10
|
+
private readonly room;
|
|
11
|
+
private tickTimer;
|
|
12
|
+
constructor(ctx: DevSyncplayRoomContext);
|
|
13
|
+
}
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createDeterministicAuthorityRoom, } from './authority-room.js';
|
|
2
|
+
import { SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
|
|
3
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
4
|
+
import { validateSyncplaySecretSystemsConfig } from './secret-config.js';
|
|
5
|
+
import { assertKinetixRuntimeIdentity, kinetixSessionConfigDigest, } from '@series-inc/rundot-kinetix/runtime';
|
|
6
|
+
/**
|
|
7
|
+
* Built-in deterministic (Syncplay) room for the dev sidecar — the playground
|
|
8
|
+
* counterpart of mp-room-server's SyncplayAuthorityGameRoom.
|
|
9
|
+
*
|
|
10
|
+
* When a rooms-config room declares `deterministic: true`, the sidecar
|
|
11
|
+
* instantiates this instead of loading the creator's GameRoom file: syncplay games
|
|
12
|
+
* have NO per-game server code — the engine's input authority orders/confirms
|
|
13
|
+
* inputs and never runs the sim. Clients speak the `rdm-local-authority/v2`
|
|
14
|
+
* session protocol under msgType `rdm`: `session-start` (unicast on join),
|
|
15
|
+
* `input` (client→server), `confirmed-input` (broadcast each authority tick).
|
|
16
|
+
*
|
|
17
|
+
* Unlike mp-room-server (whose worker harness drives `handleTick`), the sidecar
|
|
18
|
+
* has no tick driver, so this room owns its own interval at `tickRateHz`,
|
|
19
|
+
* started on create and cleared on dispose.
|
|
20
|
+
*/
|
|
21
|
+
const SESSION_MSG_TYPE = 'rdm';
|
|
22
|
+
const SECRET_MSG_TYPE = 'rdm-secret';
|
|
23
|
+
const DEFAULT_HARD_TOLERANCE_TICKS = 6;
|
|
24
|
+
const DEFAULT_REDUNDANCY_WINDOW_TICKS = 8;
|
|
25
|
+
const DEFAULT_TICK_RATE_HZ = 30;
|
|
26
|
+
const DEFAULT_MAX_PLAYERS = 2;
|
|
27
|
+
export class DevSyncplayAuthorityRoom {
|
|
28
|
+
players = new Map();
|
|
29
|
+
room;
|
|
30
|
+
tickTimer = null;
|
|
31
|
+
constructor(ctx) {
|
|
32
|
+
const config = ctx.config;
|
|
33
|
+
const maxPlayers = readPositiveInt(config.maxPlayers, DEFAULT_MAX_PLAYERS);
|
|
34
|
+
const playerCount = readPositiveInt(config.playerCount, maxPlayers);
|
|
35
|
+
const tickRateHz = readPositiveInt(config.tickRateHz, DEFAULT_TICK_RATE_HZ);
|
|
36
|
+
const deterministic = isRecord(config.deterministic) ? config.deterministic : undefined;
|
|
37
|
+
const runtime = parseRuntimeMetadata(config.syncplayRuntime);
|
|
38
|
+
const secretConfig = deterministic?.secrets === undefined
|
|
39
|
+
? undefined
|
|
40
|
+
: validateSyncplaySecretSystemsConfig(deterministic.secrets, playerCount);
|
|
41
|
+
const secretCrypto = secretConfig === undefined ? undefined : createEphemeralSecretCrypto();
|
|
42
|
+
// The server owns the seed: derive it deterministically from the room id so
|
|
43
|
+
// every client of this room receives the same seed (same scheme as prod).
|
|
44
|
+
const seed = deriveSeed(ctx.roomId);
|
|
45
|
+
this.room = createDeterministicAuthorityRoom({
|
|
46
|
+
broadcast: (msgType, payload) => ctx.protocol.broadcast(msgType, payload),
|
|
47
|
+
sendTo: (playerId, msgType, payload) => ctx.protocol.sendTo(playerId, msgType, payload),
|
|
48
|
+
}, {
|
|
49
|
+
playerCount,
|
|
50
|
+
seed,
|
|
51
|
+
// The engine accepts hardToleranceTicks >= 0 (0 = confirm immediately); honor an explicit 0.
|
|
52
|
+
hardToleranceTicks: readNonNegativeInt(config.hardToleranceTicks, DEFAULT_HARD_TOLERANCE_TICKS),
|
|
53
|
+
tickRateHz,
|
|
54
|
+
runtimeIdentity: runtime.runtimeIdentity,
|
|
55
|
+
sessionConfigBytes: runtime.sessionConfigBytes,
|
|
56
|
+
sessionConfigDigest: runtime.sessionConfigDigest,
|
|
57
|
+
redundancyWindowTicks: readPositiveInt(config.redundancyWindowTicks, DEFAULT_REDUNDANCY_WINDOW_TICKS),
|
|
58
|
+
// Game-agnostic no-op input: the authority substitutes canonical null for
|
|
59
|
+
// a slot with no prior input; the client maps null to the game's default.
|
|
60
|
+
neutralInput: null,
|
|
61
|
+
sessionMsgType: SESSION_MSG_TYPE,
|
|
62
|
+
...(secretConfig === undefined
|
|
63
|
+
? {}
|
|
64
|
+
: {
|
|
65
|
+
authorityId: ctx.roomId,
|
|
66
|
+
secrets: secretConfig,
|
|
67
|
+
secretCrypto,
|
|
68
|
+
secretMsgType: SECRET_MSG_TYPE,
|
|
69
|
+
}),
|
|
70
|
+
...(config.waitForFullRoom === true ? { waitForFullRoom: true } : {}),
|
|
71
|
+
...(typeof config.historyRetentionTicks === 'number'
|
|
72
|
+
? { historyRetentionTicks: config.historyRetentionTicks }
|
|
73
|
+
: {}),
|
|
74
|
+
// Snapshot late-join (M5.5) knobs — same names as prod (mp-room-server).
|
|
75
|
+
...(typeof config.snapshotCadenceTicks === 'number'
|
|
76
|
+
? { snapshotCadenceTicks: config.snapshotCadenceTicks }
|
|
77
|
+
: {}),
|
|
78
|
+
...(typeof config.transferTimeoutTicks === 'number'
|
|
79
|
+
? { transferTimeoutTicks: config.transferTimeoutTicks }
|
|
80
|
+
: {}),
|
|
81
|
+
...(typeof config.maxSnapshotBytes === 'number'
|
|
82
|
+
? { maxSnapshotBytes: config.maxSnapshotBytes }
|
|
83
|
+
: {}),
|
|
84
|
+
// Detection only, never enforcement — surface it to the dev console.
|
|
85
|
+
onDesync: (info) => {
|
|
86
|
+
ctx.log.warn('deterministic room desync detected', { roomId: ctx.roomId, ...info });
|
|
87
|
+
},
|
|
88
|
+
onSnapshotEvent: (event) => {
|
|
89
|
+
if (event.kind === 'rejected') {
|
|
90
|
+
ctx.log.warn('deterministic room snapshot transfer rejected', { roomId: ctx.roomId, ...event });
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
ctx.protocol.handleCreate = async () => {
|
|
95
|
+
const intervalMs = Math.max(1, Math.round(1000 / tickRateHz));
|
|
96
|
+
this.tickTimer = setInterval(() => this.room.onTick(), intervalMs);
|
|
97
|
+
// Never hold the vite dev process open on our account.
|
|
98
|
+
this.tickTimer.unref?.();
|
|
99
|
+
};
|
|
100
|
+
ctx.protocol.handleRestore = async () => { };
|
|
101
|
+
ctx.protocol.handleDispose = async () => {
|
|
102
|
+
if (this.tickTimer !== null) {
|
|
103
|
+
clearInterval(this.tickTimer);
|
|
104
|
+
this.tickTimer = null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
ctx.protocol.handleJoin = async (data) => {
|
|
108
|
+
const result = this.room.onPlayerJoin(data.id);
|
|
109
|
+
if (!result.accepted) {
|
|
110
|
+
return { accepted: false, reason: result.reason ?? 'join-rejected' };
|
|
111
|
+
}
|
|
112
|
+
const player = {
|
|
113
|
+
id: data.id,
|
|
114
|
+
username: data.username,
|
|
115
|
+
avatarUrl: data.avatarUrl ?? null,
|
|
116
|
+
joinedAt: Date.now(),
|
|
117
|
+
connected: true,
|
|
118
|
+
};
|
|
119
|
+
this.players.set(data.id, player);
|
|
120
|
+
return { accepted: true, player };
|
|
121
|
+
};
|
|
122
|
+
ctx.protocol.handleMessage = async (playerId, msgType, data) => {
|
|
123
|
+
// Grace-window socket reattach (dev-server reattach): refill any confirmed
|
|
124
|
+
// frames missed while offline. Idempotent — clients dedupe applied ticks.
|
|
125
|
+
if (msgType === SYSTEM_MSG_RECONNECTED) {
|
|
126
|
+
this.room.onPlayerReconnected(playerId);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (msgType === SECRET_MSG_TYPE) {
|
|
130
|
+
const envelope = typeof data === 'string' ? data : JSON.stringify(data);
|
|
131
|
+
this.room.onPlayerSecretMessage(playerId, envelope);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (msgType !== SESSION_MSG_TYPE)
|
|
135
|
+
return;
|
|
136
|
+
// The engine envelope is a JSON string; the transport may deliver it parsed.
|
|
137
|
+
const envelope = typeof data === 'string' ? data : JSON.stringify(data);
|
|
138
|
+
this.room.onPlayerMessage(playerId, envelope);
|
|
139
|
+
};
|
|
140
|
+
ctx.protocol.handleLeave = async (playerId) => {
|
|
141
|
+
this.room.onPlayerLeave(playerId);
|
|
142
|
+
this.players.delete(playerId);
|
|
143
|
+
};
|
|
144
|
+
// Ticking is owned by handleCreate's interval (the sidecar has no external
|
|
145
|
+
// tick driver). handleTick stays a no-op so a harness that DOES drive ticks
|
|
146
|
+
// can never double-advance the authority.
|
|
147
|
+
ctx.protocol.handleTick = async () => { };
|
|
148
|
+
ctx.protocol.serializePersistState = () => ({
|
|
149
|
+
...this.room.snapshot(),
|
|
150
|
+
...(this.room.secretSnapshot() === undefined ? {} : { secretState: this.room.secretSnapshot() }),
|
|
151
|
+
});
|
|
152
|
+
ctx.protocol.getLocked = () => false;
|
|
153
|
+
ctx.protocol.getPlayers = () => this.players;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function parseRuntimeMetadata(value) {
|
|
157
|
+
if (!isRecord(value)
|
|
158
|
+
|| Object.keys(value).sort().join('\0') !== 'runtimeIdentity\0sessionConfigBytes\0sessionConfigDigest'
|
|
159
|
+
|| typeof value.sessionConfigBytes !== 'string'
|
|
160
|
+
|| typeof value.sessionConfigDigest !== 'string') {
|
|
161
|
+
throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
|
|
162
|
+
}
|
|
163
|
+
assertKinetixRuntimeIdentity(value.runtimeIdentity);
|
|
164
|
+
const sessionConfigBytes = decodeCanonicalBase64(value.sessionConfigBytes);
|
|
165
|
+
if (sessionConfigBytes.byteLength < 1
|
|
166
|
+
|| sessionConfigBytes.byteLength > 65_536
|
|
167
|
+
|| kinetixSessionConfigDigest(sessionConfigBytes) !== value.sessionConfigDigest) {
|
|
168
|
+
throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
runtimeIdentity: value.runtimeIdentity,
|
|
172
|
+
sessionConfigBytes,
|
|
173
|
+
sessionConfigDigest: value.sessionConfigDigest,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function decodeCanonicalBase64(value) {
|
|
177
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
|
|
178
|
+
throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
|
|
179
|
+
}
|
|
180
|
+
let binary;
|
|
181
|
+
try {
|
|
182
|
+
binary = atob(value);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
|
|
186
|
+
}
|
|
187
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
188
|
+
const canonical = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''));
|
|
189
|
+
if (canonical !== value)
|
|
190
|
+
throw new Error('SYNCPLAY_RUNTIME_METADATA_INVALID');
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
193
|
+
function readPositiveInt(value, fallback) {
|
|
194
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
195
|
+
}
|
|
196
|
+
function readNonNegativeInt(value, fallback) {
|
|
197
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
198
|
+
}
|
|
199
|
+
function deriveSeed(roomId) {
|
|
200
|
+
let hash = 0x811c9dc5;
|
|
201
|
+
for (let index = 0; index < roomId.length; index += 1) {
|
|
202
|
+
hash ^= roomId.charCodeAt(index);
|
|
203
|
+
hash = Math.imul(hash, 0x01000193);
|
|
204
|
+
}
|
|
205
|
+
return hash >>> 0;
|
|
206
|
+
}
|
|
207
|
+
function createEphemeralSecretCrypto() {
|
|
208
|
+
const pair = generateKeyPairSync('ed25519');
|
|
209
|
+
const privateKey = createPrivateKey(pair.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
|
210
|
+
const spkiDer = Buffer.from(new Uint8Array(pair.publicKey.export({ type: 'spki', format: 'der' })));
|
|
211
|
+
return {
|
|
212
|
+
kid: 'dev-ephemeral',
|
|
213
|
+
publicKeySpki: spkiDer.toString('base64'),
|
|
214
|
+
randomBytes(length) {
|
|
215
|
+
return Uint8Array.from(randomBytes(length));
|
|
216
|
+
},
|
|
217
|
+
sha256(bytes) {
|
|
218
|
+
return Uint8Array.from(createHash('sha256').update(bytes).digest());
|
|
219
|
+
},
|
|
220
|
+
sign(bytes) {
|
|
221
|
+
return Uint8Array.from(sign(null, bytes, privateKey));
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function isRecord(value) {
|
|
226
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
227
|
+
}
|
package/dist/node.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export type { SyncplaySecretAuthorityCrypto, SyncplaySecretBrowserCrypto } from
|
|
|
20
20
|
export * from './ws-frame.js';
|
|
21
21
|
export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
22
22
|
export type { ColliderCookOptions, ColliderSourceMesh, CookedCollider, CookedColliderHull, } from './collider-cooking.js';
|
|
23
|
+
export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
|
package/dist/node.js
CHANGED
|
@@ -16,3 +16,6 @@ export * from './secret-authority.js';
|
|
|
16
16
|
export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrowserSyncplaySecretCrypto, hexToBytes, merkleLeafHash, merkleNodeHash, merkleProof, sha256Bytes, signedReceiptBytes, verifyHashChainReveal, verifyMerkleProof, verifySyncplaySignedReceipt, } from './secret-crypto.js';
|
|
17
17
|
export * from './ws-frame.js';
|
|
18
18
|
export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
19
|
+
// Dev-server deterministic room class (relocated from the game SDK). A game's
|
|
20
|
+
// vite.config passes this to rundotMultiplayerPlugin({ deterministicRoomClass }).
|
|
21
|
+
export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable, production-shaped client surface for deterministic multiplayer.
|
|
3
|
+
*
|
|
4
|
+
* A game joins a deterministic room by code (or creates one) and gets back a
|
|
5
|
+
* {@link NetworkedClientTransport} — the exact socket-like surface the
|
|
6
|
+
* deterministic engine's `createNetworkedSyncplayClient` consumes. There is
|
|
7
|
+
* NO per-game server logic: the server-side deterministic room (built-in input
|
|
8
|
+
* authority) is generic, and this transport is a thin, opaque pass-through of the
|
|
9
|
+
* frozen `rdm-local-authority/v2` session envelope over the `rdm` room channel.
|
|
10
|
+
*
|
|
11
|
+
* The room's raw channel ({@link ServerRoom.sendRaw}/{@link ServerRoom.onRaw})
|
|
12
|
+
* carries the session envelope verbatim; the typed-message path would spread a
|
|
13
|
+
* raw string and corrupt it, which is why the raw channel exists.
|
|
14
|
+
*/
|
|
15
|
+
import type { NetworkedClientTransport } from './networked-client.js';
|
|
16
|
+
import type { KinetixRuntimeIdentity } from './runtime-session.js';
|
|
17
|
+
import type { MultiplayerApi, ServerPlayer } from '@series-inc/rundot-game-sdk/mp-client';
|
|
18
|
+
/** Connection-health signal surfaced by {@link SyncplayRoomTransport.onConnectionEvent}. */
|
|
19
|
+
export type SyncplayConnectionEvent = {
|
|
20
|
+
readonly kind: 'error';
|
|
21
|
+
readonly message: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'disconnected';
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'reconnecting';
|
|
26
|
+
};
|
|
27
|
+
export interface SyncplayRoomOptions {
|
|
28
|
+
readonly maxPlayers?: number;
|
|
29
|
+
readonly runtimeIdentity: KinetixRuntimeIdentity;
|
|
30
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
31
|
+
readonly sessionConfigDigest: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SyncplayOccupancySnapshot {
|
|
34
|
+
readonly players: readonly ServerPlayer[];
|
|
35
|
+
readonly occupied: number;
|
|
36
|
+
readonly maxPlayers: number;
|
|
37
|
+
readonly full: boolean;
|
|
38
|
+
readonly locked: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface SyncplayRoomTransport extends NetworkedClientTransport {
|
|
41
|
+
/** The shareable room code (e.g. "HX9KWR"). */
|
|
42
|
+
readonly roomCode: string;
|
|
43
|
+
/** This client's player id, as assigned by the server on join. */
|
|
44
|
+
readonly playerId: string;
|
|
45
|
+
readonly occupancy: SyncplayOccupancySnapshot;
|
|
46
|
+
readonly isCreator: boolean;
|
|
47
|
+
/** Leave the deterministic room and close the underlying connection. */
|
|
48
|
+
close(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Connection-health signal: socket errors, disconnects, and reconnect
|
|
51
|
+
* attempts. Without observing this, a mid-match socket drop is silent — the
|
|
52
|
+
* room layer no-ops sends while dead, so the engine would predict into a
|
|
53
|
+
* dead socket forever. Games should surface these to the player and/or tear
|
|
54
|
+
* the match down.
|
|
55
|
+
*/
|
|
56
|
+
onConnectionEvent(handler: (event: SyncplayConnectionEvent) => void): void;
|
|
57
|
+
onOccupancyChange(handler: (snapshot: SyncplayOccupancySnapshot) => void): () => void;
|
|
58
|
+
setSeatingOpen(open: boolean): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Join an existing deterministic room by its 6-char code and return a transport
|
|
62
|
+
* ready to hand to `createNetworkedSyncplayClient`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function joinSyncplayRoomByCode(api: MultiplayerApi, code: string): Promise<SyncplayRoomTransport>;
|
|
65
|
+
/** Options for {@link quickMatchSyncplayRoom}. */
|
|
66
|
+
export interface QuickMatchSyncplayOptions extends SyncplayRoomOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Auto-inject a coarse `region` criterion (default true). See
|
|
69
|
+
* {@link quickMatchSyncplayRoom} for the semantics and the tradeoff.
|
|
70
|
+
*/
|
|
71
|
+
readonly autoRegion?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Quick-match into a deterministic room (M9): the platform's joinOrCreate
|
|
75
|
+
* matchmaking pools compatible players into an open room or creates a fresh
|
|
76
|
+
* one. Optional `criteria` narrow the pool (matched exactly by the platform).
|
|
77
|
+
* With drop-in defaults the match starts immediately; unfilled slots
|
|
78
|
+
* substitute the canonical null input, which a game can treat as
|
|
79
|
+
* "bot-controlled" deterministically (see SYNCPLAY.md — Bots & quick match).
|
|
80
|
+
*
|
|
81
|
+
* Region-aware by default (G6): a coarse `region` criterion ('na' | 'sa' |
|
|
82
|
+
* 'eu' | 'africa' | 'asia' | 'oceania', derived from the device timezone) is
|
|
83
|
+
* auto-injected so cross-ocean pairings — whose RTT forces constant deep
|
|
84
|
+
* rollback — don't happen by accident. An explicit `criteria.region` wins
|
|
85
|
+
* over the derived value, and an underivable region ('unknown') is never
|
|
86
|
+
* injected: pooling globally beats fragmenting into an 'unknown' bucket.
|
|
87
|
+
*
|
|
88
|
+
* Tradeoff: strict region pools can go thin for low-traffic games. Pass
|
|
89
|
+
* `{ autoRegion: false }` (or your own `region` value) to pool globally;
|
|
90
|
+
* cross-region fallback-after-timeout is future work.
|
|
91
|
+
*/
|
|
92
|
+
export declare function quickMatchSyncplayRoom(api: MultiplayerApi, options: QuickMatchSyncplayOptions, criteria?: Record<string, string | number>): Promise<SyncplayRoomTransport>;
|
|
93
|
+
/**
|
|
94
|
+
* Create a new deterministic room and return a transport ready to hand to
|
|
95
|
+
* `createNetworkedSyncplayClient`. The server mints the room code; read it
|
|
96
|
+
* back from {@link SyncplayRoomTransport.roomCode} to share with peers.
|
|
97
|
+
* (Caller-chosen codes are not supported on create — when the platform grows
|
|
98
|
+
* that path, this signature gains a typed parameter rather than a dead one.)
|
|
99
|
+
*/
|
|
100
|
+
export declare function createSyncplayRoom(api: MultiplayerApi, options: SyncplayRoomOptions): Promise<SyncplayRoomTransport>;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable, production-shaped client surface for deterministic multiplayer.
|
|
3
|
+
*
|
|
4
|
+
* A game joins a deterministic room by code (or creates one) and gets back a
|
|
5
|
+
* {@link NetworkedClientTransport} — the exact socket-like surface the
|
|
6
|
+
* deterministic engine's `createNetworkedSyncplayClient` consumes. There is
|
|
7
|
+
* NO per-game server logic: the server-side deterministic room (built-in input
|
|
8
|
+
* authority) is generic, and this transport is a thin, opaque pass-through of the
|
|
9
|
+
* frozen `rdm-local-authority/v2` session envelope over the `rdm` room channel.
|
|
10
|
+
*
|
|
11
|
+
* The room's raw channel ({@link ServerRoom.sendRaw}/{@link ServerRoom.onRaw})
|
|
12
|
+
* carries the session envelope verbatim; the typed-message path would spread a
|
|
13
|
+
* raw string and corrupt it, which is why the raw channel exists.
|
|
14
|
+
*/
|
|
15
|
+
import { detectCoarseRegion } from './coarse-region.js';
|
|
16
|
+
/** Wire envelope msgType the deterministic session protocol travels under. */
|
|
17
|
+
const SESSION_MSG_TYPE = 'rdm';
|
|
18
|
+
const SECRET_MSG_TYPE = 'rdm-secret';
|
|
19
|
+
/** Room type the platform registers the generic deterministic room under. */
|
|
20
|
+
const SYNCPLAY_ROOM_TYPE = 'deterministic';
|
|
21
|
+
const SYNCPLAY_MAX_PLAYERS_CRITERION = '__syncplay.maxPlayers';
|
|
22
|
+
const SYNCPLAY_ENGINE_IDENTITY_CRITERION = '__syncplay.engineIdentityHash';
|
|
23
|
+
const SYNCPLAY_CONFIG_DIGEST_CRITERION = '__syncplay.sessionConfigDigest';
|
|
24
|
+
const SYNCPLAY_RUNTIME_METADATA_KEY = 'syncplayRuntime';
|
|
25
|
+
const MAX_SESSION_CONFIG_BYTES = 65_536;
|
|
26
|
+
/**
|
|
27
|
+
* Session traffic buffered between transport creation and the engine attaching
|
|
28
|
+
* its onMessage handler (a game may await asset loads in between). ~4.5 min of
|
|
29
|
+
* confirmed-input at 30 Hz — past that the transport fails LOUD (error event +
|
|
30
|
+
* close) instead of silently dropping frames, which would wedge the engine's
|
|
31
|
+
* contiguous-tick application forever.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_EARLY_SESSION_MESSAGES = 8192;
|
|
34
|
+
function toTransport(room) {
|
|
35
|
+
const connectionHandlers = [];
|
|
36
|
+
const emitConnectionEvent = (event) => {
|
|
37
|
+
for (const handler of connectionHandlers)
|
|
38
|
+
handler(event);
|
|
39
|
+
};
|
|
40
|
+
const occupancyHandlers = new Set();
|
|
41
|
+
const occupancy = () => {
|
|
42
|
+
const players = Object.freeze(room.players.map((player) => Object.freeze({ ...player })));
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
players,
|
|
45
|
+
occupied: players.length,
|
|
46
|
+
maxPlayers: room.maxPlayers,
|
|
47
|
+
full: players.length >= room.maxPlayers,
|
|
48
|
+
locked: room.locked,
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const emitOccupancy = () => {
|
|
52
|
+
const snapshot = occupancy();
|
|
53
|
+
for (const handler of occupancyHandlers)
|
|
54
|
+
handler(snapshot);
|
|
55
|
+
};
|
|
56
|
+
// Fail-closed wiring: a transport that only carries send/onMessage swallows
|
|
57
|
+
// every disconnect — the room layer silently no-ops sends while the socket is
|
|
58
|
+
// dead, so the engine would keep predicting with no confirmed-input and the
|
|
59
|
+
// game would freeze with no signal. Register the health handlers up front
|
|
60
|
+
// (RundotServerRoom.on merges, so a game's own handlers are unaffected).
|
|
61
|
+
room.on({
|
|
62
|
+
onError: (error) => emitConnectionEvent({ kind: 'error', message: error }),
|
|
63
|
+
onDisconnect: () => emitConnectionEvent({ kind: 'disconnected' }),
|
|
64
|
+
onReconnecting: () => emitConnectionEvent({ kind: 'reconnecting' }),
|
|
65
|
+
onPlayerJoined: emitOccupancy,
|
|
66
|
+
onPlayerLeft: emitOccupancy,
|
|
67
|
+
onLock: emitOccupancy,
|
|
68
|
+
onUnlock: emitOccupancy,
|
|
69
|
+
});
|
|
70
|
+
// Register the raw handler EAGERLY — synchronously, before this function
|
|
71
|
+
// returns — so no `rdm` payload ever races the game's client construction.
|
|
72
|
+
// The deterministic authority emits `session-start` right after join, and
|
|
73
|
+
// `confirmed-input` starts flowing immediately; anything received before the
|
|
74
|
+
// engine attaches via onMessage is buffered here in order and flushed on
|
|
75
|
+
// attach.
|
|
76
|
+
let messageHandler;
|
|
77
|
+
let earlyMessages = [];
|
|
78
|
+
let secretMessageHandler;
|
|
79
|
+
let earlySecretMessages = [];
|
|
80
|
+
room.onRaw(SESSION_MSG_TYPE, (data) => {
|
|
81
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data);
|
|
82
|
+
if (messageHandler !== undefined) {
|
|
83
|
+
messageHandler(text);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (earlyMessages.length >= MAX_EARLY_SESSION_MESSAGES) {
|
|
87
|
+
emitConnectionEvent({
|
|
88
|
+
kind: 'error',
|
|
89
|
+
message: 'syncplay transport buffered too much session traffic with no onMessage attached — closing the room instead of silently dropping frames',
|
|
90
|
+
});
|
|
91
|
+
room.leave();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
earlyMessages.push(text);
|
|
95
|
+
});
|
|
96
|
+
room.onRaw(SECRET_MSG_TYPE, (data) => {
|
|
97
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data);
|
|
98
|
+
if (secretMessageHandler !== undefined) {
|
|
99
|
+
secretMessageHandler(text);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (earlySecretMessages.length >= MAX_EARLY_SESSION_MESSAGES) {
|
|
103
|
+
emitConnectionEvent({
|
|
104
|
+
kind: 'error',
|
|
105
|
+
message: 'syncplay transport buffered too much secret traffic with no onSecretMessage attached — closing the room instead of silently dropping frames',
|
|
106
|
+
});
|
|
107
|
+
room.leave();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
earlySecretMessages.push(text);
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
roomCode: room.roomCode,
|
|
114
|
+
playerId: room.playerId,
|
|
115
|
+
get occupancy() {
|
|
116
|
+
return occupancy();
|
|
117
|
+
},
|
|
118
|
+
isCreator: room.isCreator,
|
|
119
|
+
send: (text) => room.sendRaw(SESSION_MSG_TYPE, text),
|
|
120
|
+
onMessage: (handler) => {
|
|
121
|
+
messageHandler = handler;
|
|
122
|
+
const buffered = earlyMessages;
|
|
123
|
+
earlyMessages = [];
|
|
124
|
+
for (const text of buffered)
|
|
125
|
+
handler(text);
|
|
126
|
+
},
|
|
127
|
+
sendSecret: (text) => room.sendRaw(SECRET_MSG_TYPE, text),
|
|
128
|
+
onSecretMessage: (handler) => {
|
|
129
|
+
secretMessageHandler = handler;
|
|
130
|
+
const buffered = earlySecretMessages;
|
|
131
|
+
earlySecretMessages = [];
|
|
132
|
+
for (const text of buffered)
|
|
133
|
+
handler(text);
|
|
134
|
+
},
|
|
135
|
+
close: () => room.leave(),
|
|
136
|
+
onConnectionEvent: (handler) => {
|
|
137
|
+
connectionHandlers.push(handler);
|
|
138
|
+
},
|
|
139
|
+
onOccupancyChange: (handler) => {
|
|
140
|
+
occupancyHandlers.add(handler);
|
|
141
|
+
handler(occupancy());
|
|
142
|
+
return () => {
|
|
143
|
+
occupancyHandlers.delete(handler);
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
setSeatingOpen: (open) => room.setSeatingOpen(open),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Join an existing deterministic room by its 6-char code and return a transport
|
|
151
|
+
* ready to hand to `createNetworkedSyncplayClient`.
|
|
152
|
+
*/
|
|
153
|
+
export async function joinSyncplayRoomByCode(api, code) {
|
|
154
|
+
const room = await api.joinRoomByCode(code);
|
|
155
|
+
return toTransport(room);
|
|
156
|
+
}
|
|
157
|
+
function validateMaxPlayers(maxPlayers) {
|
|
158
|
+
if (maxPlayers !== undefined && (!Number.isInteger(maxPlayers) || maxPlayers <= 0)) {
|
|
159
|
+
throw new Error('maxPlayers must be a positive integer');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function runtimeMetadata(options) {
|
|
163
|
+
if (options === undefined)
|
|
164
|
+
throw new Error('Syncplay runtime identity and canonical session config are required');
|
|
165
|
+
const identity = options.runtimeIdentity;
|
|
166
|
+
if (identity === undefined || identity.abiVersion !== 1 || ![10, 20, 30, 60].includes(identity.tickRate)
|
|
167
|
+
|| identity.inputSchemaId.length === 0 || identity.stateSchemaId.length === 0
|
|
168
|
+
|| identity.deterministicVersion.length === 0 || identity.engineIdentityHash.length === 0) {
|
|
169
|
+
throw new Error('Syncplay runtime identity is invalid');
|
|
170
|
+
}
|
|
171
|
+
if (!(options.sessionConfigBytes instanceof Uint8Array)
|
|
172
|
+
|| options.sessionConfigBytes.byteLength < 1
|
|
173
|
+
|| options.sessionConfigBytes.byteLength > MAX_SESSION_CONFIG_BYTES) {
|
|
174
|
+
throw new Error('Syncplay canonical session config bytes are empty or oversized');
|
|
175
|
+
}
|
|
176
|
+
if (!/^[a-f0-9]{64}$/u.test(options.sessionConfigDigest)) {
|
|
177
|
+
throw new Error('Syncplay session config digest is invalid');
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
[SYNCPLAY_RUNTIME_METADATA_KEY]: {
|
|
181
|
+
runtimeIdentity: { ...identity },
|
|
182
|
+
sessionConfigBytes: bytesToBase64(options.sessionConfigBytes),
|
|
183
|
+
sessionConfigDigest: options.sessionConfigDigest,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function withRuntimeCriteria(criteria, options) {
|
|
188
|
+
runtimeMetadata(options);
|
|
189
|
+
return {
|
|
190
|
+
...criteria,
|
|
191
|
+
[SYNCPLAY_ENGINE_IDENTITY_CRITERION]: options.runtimeIdentity.engineIdentityHash,
|
|
192
|
+
[SYNCPLAY_CONFIG_DIGEST_CRITERION]: options.sessionConfigDigest,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function withRequestedCapacity(criteria, maxPlayers) {
|
|
196
|
+
validateMaxPlayers(maxPlayers);
|
|
197
|
+
if (maxPlayers === undefined)
|
|
198
|
+
return criteria;
|
|
199
|
+
return { ...criteria, [SYNCPLAY_MAX_PLAYERS_CRITERION]: maxPlayers };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Auto-region (G6): pools pair on strict criteria equality, so tagging the
|
|
203
|
+
* request with the device's coarse region regionalizes the pool key with no
|
|
204
|
+
* server change. An explicit caller `region` always wins; a derived 'unknown'
|
|
205
|
+
* is NOT injected (better to pool globally than fragment into an 'unknown'
|
|
206
|
+
* bucket).
|
|
207
|
+
*/
|
|
208
|
+
function withAutoRegion(criteria, autoRegion) {
|
|
209
|
+
if (!autoRegion)
|
|
210
|
+
return criteria;
|
|
211
|
+
if (criteria !== undefined && 'region' in criteria)
|
|
212
|
+
return criteria;
|
|
213
|
+
const region = detectCoarseRegion();
|
|
214
|
+
if (region === 'unknown')
|
|
215
|
+
return criteria;
|
|
216
|
+
return { ...criteria, region };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Quick-match into a deterministic room (M9): the platform's joinOrCreate
|
|
220
|
+
* matchmaking pools compatible players into an open room or creates a fresh
|
|
221
|
+
* one. Optional `criteria` narrow the pool (matched exactly by the platform).
|
|
222
|
+
* With drop-in defaults the match starts immediately; unfilled slots
|
|
223
|
+
* substitute the canonical null input, which a game can treat as
|
|
224
|
+
* "bot-controlled" deterministically (see SYNCPLAY.md — Bots & quick match).
|
|
225
|
+
*
|
|
226
|
+
* Region-aware by default (G6): a coarse `region` criterion ('na' | 'sa' |
|
|
227
|
+
* 'eu' | 'africa' | 'asia' | 'oceania', derived from the device timezone) is
|
|
228
|
+
* auto-injected so cross-ocean pairings — whose RTT forces constant deep
|
|
229
|
+
* rollback — don't happen by accident. An explicit `criteria.region` wins
|
|
230
|
+
* over the derived value, and an underivable region ('unknown') is never
|
|
231
|
+
* injected: pooling globally beats fragmenting into an 'unknown' bucket.
|
|
232
|
+
*
|
|
233
|
+
* Tradeoff: strict region pools can go thin for low-traffic games. Pass
|
|
234
|
+
* `{ autoRegion: false }` (or your own `region` value) to pool globally;
|
|
235
|
+
* cross-region fallback-after-timeout is future work.
|
|
236
|
+
*/
|
|
237
|
+
export async function quickMatchSyncplayRoom(api, options, criteria) {
|
|
238
|
+
const effectiveCriteria = withRequestedCapacity(withRuntimeCriteria(withAutoRegion(criteria, options?.autoRegion ?? true), options), options?.maxPlayers);
|
|
239
|
+
const roomOptions = {
|
|
240
|
+
...(effectiveCriteria === undefined ? {} : { criteria: effectiveCriteria }),
|
|
241
|
+
createOptions: {
|
|
242
|
+
...(options?.maxPlayers === undefined ? {} : { maxPlayers: options.maxPlayers }),
|
|
243
|
+
metadata: runtimeMetadata(options),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const room = await api.joinOrCreateRoom(SYNCPLAY_ROOM_TYPE, Object.keys(roomOptions).length === 0 ? undefined : roomOptions);
|
|
247
|
+
return toTransport(room);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Create a new deterministic room and return a transport ready to hand to
|
|
251
|
+
* `createNetworkedSyncplayClient`. The server mints the room code; read it
|
|
252
|
+
* back from {@link SyncplayRoomTransport.roomCode} to share with peers.
|
|
253
|
+
* (Caller-chosen codes are not supported on create — when the platform grows
|
|
254
|
+
* that path, this signature gains a typed parameter rather than a dead one.)
|
|
255
|
+
*/
|
|
256
|
+
export async function createSyncplayRoom(api, options) {
|
|
257
|
+
validateMaxPlayers(options?.maxPlayers);
|
|
258
|
+
const metadata = runtimeMetadata(options);
|
|
259
|
+
const room = await api.createRoom(SYNCPLAY_ROOM_TYPE, {
|
|
260
|
+
createOptions: {
|
|
261
|
+
...(options?.maxPlayers === undefined ? {} : { maxPlayers: options.maxPlayers }),
|
|
262
|
+
metadata,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
return toTransport(room);
|
|
266
|
+
}
|
|
267
|
+
function bytesToBase64(bytes) {
|
|
268
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
269
|
+
let output = '';
|
|
270
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
271
|
+
const b0 = bytes[index];
|
|
272
|
+
const b1 = index + 1 < bytes.length ? bytes[index + 1] : 0;
|
|
273
|
+
const b2 = index + 2 < bytes.length ? bytes[index + 2] : 0;
|
|
274
|
+
output += alphabet[b0 >> 2];
|
|
275
|
+
output += alphabet[((b0 & 3) << 4) | (b1 >> 4)];
|
|
276
|
+
output += index + 1 < bytes.length ? alphabet[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
|
277
|
+
output += index + 2 < bytes.length ? alphabet[b2 & 63] : '=';
|
|
278
|
+
}
|
|
279
|
+
return output;
|
|
280
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { type KinetixRuntime, type KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
|
|
2
2
|
import type { RecordedChecksum, RecordedCommandFrame, RecordedInputFlags, RecordedInputFrame, ReplayFile } from './types.js';
|
|
3
3
|
export type { KinetixRuntime, KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
|
|
4
|
+
/**
|
|
5
|
+
* A Kinetix runtime that may expose the optional no-clone presentation
|
|
6
|
+
* projection, re-typed for this session's Projection.
|
|
7
|
+
*
|
|
8
|
+
* `project` is omitted and re-declared rather than inherited so this compiles
|
|
9
|
+
* against both Kinetix builds we resolve against: the published ABI v1 runtime
|
|
10
|
+
* (no `project`) that the lockfile pins and publish lanes keep, and the
|
|
11
|
+
* in-repo runtime (whose `project` defaults to `Projection = State`) that test
|
|
12
|
+
* lanes overlay. Presence is feature-detected at runtime either way.
|
|
13
|
+
*/
|
|
14
|
+
export type ProjectionCapableRuntime<State, Input, Checkpoint, Projection> = Omit<KinetixRuntime<State, Input, Checkpoint>, 'project'> & {
|
|
15
|
+
project?(checkpoint: Checkpoint, previous?: Checkpoint): Readonly<Projection>;
|
|
16
|
+
};
|
|
4
17
|
export interface RuntimeExternalSnapshot<Input> {
|
|
5
18
|
readonly frame: number;
|
|
6
19
|
readonly runtimeBytes: Uint8Array;
|
|
@@ -16,8 +29,8 @@ export interface RuntimeFrameSnapshot<State, Input, Checkpoint> {
|
|
|
16
29
|
readonly checkpoint: Checkpoint;
|
|
17
30
|
readonly state: State;
|
|
18
31
|
}
|
|
19
|
-
export interface RuntimeSessionConfig<State, Input, Checkpoint> {
|
|
20
|
-
readonly runtime:
|
|
32
|
+
export interface RuntimeSessionConfig<State, Input, Checkpoint, Projection = State> {
|
|
33
|
+
readonly runtime: ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
|
|
21
34
|
readonly playerCount: number;
|
|
22
35
|
readonly defaultInput: Input;
|
|
23
36
|
readonly snapshotBufferSize?: number;
|
|
@@ -30,7 +43,7 @@ export interface RuntimeSessionConfig<State, Input, Checkpoint> {
|
|
|
30
43
|
readonly onCommandFrameRecorded?: (frame: RecordedCommandFrame) => void;
|
|
31
44
|
readonly onChecksumRecorded?: (checksum: RecordedChecksum) => void;
|
|
32
45
|
}
|
|
33
|
-
export interface RuntimeSession<State, Input, Checkpoint = unknown> {
|
|
46
|
+
export interface RuntimeSession<State, Input, Checkpoint = unknown, Projection = State> {
|
|
34
47
|
readonly currentFrame: number;
|
|
35
48
|
readonly accumulatedMs: number;
|
|
36
49
|
readonly schemaIdentity: KinetixRuntimeIdentity;
|
|
@@ -40,6 +53,13 @@ export interface RuntimeSession<State, Input, Checkpoint = unknown> {
|
|
|
40
53
|
update(deltaMs: number): number;
|
|
41
54
|
stepFrames(count: number): void;
|
|
42
55
|
getState(): State;
|
|
56
|
+
/**
|
|
57
|
+
* Projects the current frame for presentation without cloning. Requires a
|
|
58
|
+
* runtime that implements `project`; throws SYNCPLAY_PROJECTION_UNSUPPORTED
|
|
59
|
+
* otherwise. The result is immutable and may share structure with previous
|
|
60
|
+
* frames — callers must not mutate it.
|
|
61
|
+
*/
|
|
62
|
+
getPresentationFrame(): Readonly<Projection>;
|
|
43
63
|
exportReplay(): ReplayFile;
|
|
44
64
|
peekSnapshot(frame: number): RuntimeFrameSnapshot<State, Input, Checkpoint> | undefined;
|
|
45
65
|
getSnapshot(frame: number): RuntimeFrameSnapshot<State, Input, Checkpoint> | undefined;
|
|
@@ -53,4 +73,4 @@ export interface RuntimeSession<State, Input, Checkpoint = unknown> {
|
|
|
53
73
|
getSnapshotFrames(): readonly number[];
|
|
54
74
|
dispose(): void;
|
|
55
75
|
}
|
|
56
|
-
export declare function createRuntimeSession<State, Input, Checkpoint>(config: RuntimeSessionConfig<State, Input, Checkpoint>): RuntimeSession<State, Input, Checkpoint>;
|
|
76
|
+
export declare function createRuntimeSession<State, Input, Checkpoint, Projection = State>(config: RuntimeSessionConfig<State, Input, Checkpoint, Projection>): RuntimeSession<State, Input, Checkpoint, Projection>;
|
package/dist/runtime-session.js
CHANGED
|
@@ -38,6 +38,7 @@ export function createRuntimeSession(config) {
|
|
|
38
38
|
let accumulatedMs = 0;
|
|
39
39
|
let disposed = false;
|
|
40
40
|
let hydrated = false;
|
|
41
|
+
let lastProjected;
|
|
41
42
|
const frame0 = config.runtime.capture();
|
|
42
43
|
retained.set(config.runtime.currentFrame, {
|
|
43
44
|
checkpoint: frame0,
|
|
@@ -116,7 +117,19 @@ export function createRuntimeSession(config) {
|
|
|
116
117
|
checksum: frame % checksumInterval === 0,
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
+
let results;
|
|
121
|
+
try {
|
|
122
|
+
results = config.runtime.advance(requests);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// advance() is atomic: the batch stepped no frames, so its commands must
|
|
126
|
+
// not be replayed on the next attempt. A command the runtime refuses to
|
|
127
|
+
// decode would otherwise be rebuilt into every subsequent request and
|
|
128
|
+
// wedge the session permanently.
|
|
129
|
+
for (const request of requests)
|
|
130
|
+
pendingCommands.delete(request.frame);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
120
133
|
if (results.length !== requests.length)
|
|
121
134
|
throw new Error('SYNCPLAY_RUNTIME_RESULT_COUNT_MISMATCH');
|
|
122
135
|
for (let index = 0; index < results.length; index += 1) {
|
|
@@ -217,6 +230,29 @@ export function createRuntimeSession(config) {
|
|
|
217
230
|
active();
|
|
218
231
|
return structuredClone(config.runtime.inspect(requireRetained(config.runtime.currentFrame).checkpoint));
|
|
219
232
|
},
|
|
233
|
+
getPresentationFrame() {
|
|
234
|
+
active();
|
|
235
|
+
const project = config.runtime.project;
|
|
236
|
+
if (typeof project !== 'function')
|
|
237
|
+
throw new Error('SYNCPLAY_PROJECTION_UNSUPPORTED');
|
|
238
|
+
const frame = config.runtime.currentFrame;
|
|
239
|
+
const entry = requireRetained(frame);
|
|
240
|
+
// Re-reading a frame must not diff it against itself — a delta hook given
|
|
241
|
+
// the current frame as its own baseline would report nothing changed.
|
|
242
|
+
// State cannot move without the frame advancing, so the projection this
|
|
243
|
+
// frame already produced is still the answer.
|
|
244
|
+
if (lastProjected?.frame === frame)
|
|
245
|
+
return lastProjected.projection;
|
|
246
|
+
// A previously projected frame may have been pruned or rolled past; the
|
|
247
|
+
// runtime then gets no baseline and produces a full frame. Deliberate
|
|
248
|
+
// contract, not a fallback.
|
|
249
|
+
const previous = lastProjected === undefined
|
|
250
|
+
? undefined
|
|
251
|
+
: retained.get(lastProjected.frame)?.checkpoint;
|
|
252
|
+
const projection = project.call(config.runtime, entry.checkpoint, previous);
|
|
253
|
+
lastProjected = { frame, projection };
|
|
254
|
+
return projection;
|
|
255
|
+
},
|
|
220
256
|
exportReplay() {
|
|
221
257
|
active();
|
|
222
258
|
if (hydrated)
|
|
@@ -279,6 +315,7 @@ export function createRuntimeSession(config) {
|
|
|
279
315
|
commandFrames.splice(0, commandFrames.length, ...commandFrames.filter((record) => record.frame <= frame));
|
|
280
316
|
checksums.splice(0, checksums.length, ...checksums.filter((record) => record.frame <= frame));
|
|
281
317
|
accumulatedMs = 0;
|
|
318
|
+
lastProjected = undefined;
|
|
282
319
|
},
|
|
283
320
|
restoreSnapshot(snapshot) {
|
|
284
321
|
active();
|
|
@@ -316,6 +353,7 @@ export function createRuntimeSession(config) {
|
|
|
316
353
|
commandFrames.length = 0;
|
|
317
354
|
checksums.length = 0;
|
|
318
355
|
accumulatedMs = 0;
|
|
356
|
+
lastProjected = undefined;
|
|
319
357
|
hydrated = true;
|
|
320
358
|
},
|
|
321
359
|
getSnapshotFrames() { active(); return [...retained.keys()]; },
|
|
@@ -325,6 +363,9 @@ export function createRuntimeSession(config) {
|
|
|
325
363
|
for (const entry of retained.values())
|
|
326
364
|
release(entry.checkpoint);
|
|
327
365
|
retained.clear();
|
|
366
|
+
// A projection may share structure with the whole world; releasing the
|
|
367
|
+
// checkpoints while still pinning it would defeat the release.
|
|
368
|
+
lastProjected = undefined;
|
|
328
369
|
disposed = true;
|
|
329
370
|
},
|
|
330
371
|
};
|
package/dist/sdk-session.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
export type
|
|
4
|
-
export
|
|
5
|
-
export declare function createSyncplaySession<State, Input, Checkpoint = unknown>(runtime: KinetixRuntime<State, Input, Checkpoint>, policy: SyncplaySessionPolicy<State, Input, Checkpoint>): SyncplaySession<State, Input, Checkpoint>;
|
|
1
|
+
import { type ProjectionCapableRuntime, type RuntimeSession, type RuntimeSessionConfig } from './runtime-session.js';
|
|
2
|
+
export type SyncplaySessionPolicy<State, Input, Checkpoint = unknown, Projection = State> = Omit<RuntimeSessionConfig<State, Input, Checkpoint, Projection>, 'runtime'>;
|
|
3
|
+
export type SyncplaySession<State, Input, Checkpoint = unknown, Projection = State> = RuntimeSession<State, Input, Checkpoint, Projection>;
|
|
4
|
+
export declare function createSyncplaySession<State, Input, Checkpoint = unknown, Projection = State>(runtime: ProjectionCapableRuntime<State, Input, Checkpoint, Projection>, policy: SyncplaySessionPolicy<State, Input, Checkpoint, Projection>): SyncplaySession<State, Input, Checkpoint, Projection>;
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { defineKinetixProject } from '@series-inc/rundot-kinetix/authoring';
|
|
|
2
2
|
export type { KinetixProjectDeclaration } from '@series-inc/rundot-kinetix/authoring';
|
|
3
3
|
export { checkDeterminism, checkSourceText, collectDeterminismSuppressions, type DeterminismCheckMode, type DeterminismCheckOptions, type DeterminismDiagnostic, type DeterminismParserKind, type DeterminismSeverity, type DeterminismSuppression, } from './static-checker.js';
|
|
4
4
|
export { runSyncplaySynctest, type SyncplaySynctestConfig, type SyncplaySynctestDivergence, type SyncplaySynctestInputFuzz, type SyncplaySynctestPhase, type SyncplaySynctestResult, } from '../synctest.js';
|
|
5
|
+
export { rundotSyncplayPlugin, createSyncplayViteConfig, withSyncplayViteConfig, formatDiagnostics, type SyncplayPluginOptions, type MinimalVitePlugin, type MinimalVitePluginApply, type MinimalVitePluginEnforce, type MinimalVitePluginEnvironment, type MinimalViteResolvedConfig, type MinimalViteConfig, } from './vite-plugin.js';
|
package/dist/tools/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { defineKinetixProject } from '@series-inc/rundot-kinetix/authoring';
|
|
2
2
|
export { checkDeterminism, checkSourceText, collectDeterminismSuppressions, } from './static-checker.js';
|
|
3
3
|
export { runSyncplaySynctest, } from '../synctest.js';
|
|
4
|
+
// Determinism-lint Vite plugin (relocated from the game SDK's syncplay/tools).
|
|
5
|
+
export { rundotSyncplayPlugin, createSyncplayViteConfig, withSyncplayViteConfig, formatDiagnostics, } from './vite-plugin.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type DeterminismCheckMode, type DeterminismDiagnostic } from './static-checker.js';
|
|
2
|
+
export interface SyncplayPluginOptions {
|
|
3
|
+
readonly mode?: DeterminismCheckMode;
|
|
4
|
+
readonly simulationEntries?: readonly string[];
|
|
5
|
+
readonly apply?: MinimalVitePluginApply;
|
|
6
|
+
readonly enforce?: MinimalVitePluginEnforce;
|
|
7
|
+
}
|
|
8
|
+
export type MinimalVitePluginApply = 'serve' | 'build' | ((config: unknown, env: MinimalVitePluginEnvironment) => boolean);
|
|
9
|
+
export type MinimalVitePluginEnforce = 'pre' | 'post';
|
|
10
|
+
export interface MinimalVitePluginEnvironment {
|
|
11
|
+
readonly command: 'serve' | 'build';
|
|
12
|
+
readonly mode: string;
|
|
13
|
+
readonly isSsrBuild?: boolean;
|
|
14
|
+
readonly isPreview?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** The slice of Vite's ResolvedConfig this plugin reads in configResolved. */
|
|
17
|
+
export interface MinimalViteResolvedConfig {
|
|
18
|
+
readonly root?: string;
|
|
19
|
+
readonly logger?: {
|
|
20
|
+
warn(msg: string): void;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface MinimalVitePlugin {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly apply?: MinimalVitePluginApply;
|
|
26
|
+
readonly enforce?: MinimalVitePluginEnforce;
|
|
27
|
+
configResolved?(config: MinimalViteResolvedConfig): void;
|
|
28
|
+
buildStart(): Promise<void>;
|
|
29
|
+
closeBundle?(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface MinimalViteConfig {
|
|
32
|
+
readonly plugins?: readonly (MinimalVitePlugin | false | null | undefined)[];
|
|
33
|
+
readonly [key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
export declare function rundotSyncplayPlugin(options: SyncplayPluginOptions): MinimalVitePlugin;
|
|
36
|
+
export declare function createSyncplayViteConfig(options: SyncplayPluginOptions): MinimalViteConfig;
|
|
37
|
+
export declare function withSyncplayViteConfig<TConfig extends MinimalViteConfig>(config: TConfig, options: SyncplayPluginOptions): TConfig & {
|
|
38
|
+
readonly plugins: readonly (MinimalVitePlugin | false | null | undefined)[];
|
|
39
|
+
};
|
|
40
|
+
export declare function formatDiagnostics(diagnostics: readonly DeterminismDiagnostic[]): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { checkDeterminism, } from './static-checker.js';
|
|
4
|
+
export function rundotSyncplayPlugin(options) {
|
|
5
|
+
let viteRoot;
|
|
6
|
+
let warnLog = (msg) => console.warn(msg);
|
|
7
|
+
return {
|
|
8
|
+
name: 'rundot-syncplay',
|
|
9
|
+
apply: options.apply,
|
|
10
|
+
enforce: options.enforce,
|
|
11
|
+
configResolved(config) {
|
|
12
|
+
viteRoot = config.root;
|
|
13
|
+
if (config.logger !== undefined) {
|
|
14
|
+
warnLog = (msg) => config.logger.warn(msg);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
async buildStart() {
|
|
18
|
+
const mode = options.mode ?? 'warn';
|
|
19
|
+
const strict = mode === 'strict';
|
|
20
|
+
const simulationEntries = options.simulationEntries ?? [];
|
|
21
|
+
if (simulationEntries.length === 0) {
|
|
22
|
+
// In strict mode a missing entries list is a build error: a deployable
|
|
23
|
+
// SyncPlay build must certify SOMETHING, not silently skip the check.
|
|
24
|
+
if (strict) {
|
|
25
|
+
throw new Error('[rundot-syncplay] simulationEntries is required in strict mode — '
|
|
26
|
+
+ 'point it at the deterministic simulation file(s) or directory to certify.');
|
|
27
|
+
}
|
|
28
|
+
warnLog('[rundot-syncplay] no simulationEntries configured — determinism checking is OFF for this build');
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Entries are LITERAL file/directory paths resolved against the Vite
|
|
32
|
+
// root (matching runProjectCertification/runProjectCheck's cwd
|
|
33
|
+
// behavior) — glob patterns are not expanded.
|
|
34
|
+
const root = viteRoot ?? process.cwd();
|
|
35
|
+
const resolvedEntries = [];
|
|
36
|
+
for (const entry of simulationEntries) {
|
|
37
|
+
if (/[*?[\]{}]/.test(entry)) {
|
|
38
|
+
throw new Error(`[rundot-syncplay] simulationEntries takes literal file or directory paths, not globs: "${entry}". `
|
|
39
|
+
+ 'Point it at a directory to check everything under it.');
|
|
40
|
+
}
|
|
41
|
+
const resolved = resolve(root, entry);
|
|
42
|
+
try {
|
|
43
|
+
await stat(resolved);
|
|
44
|
+
resolvedEntries.push(resolved);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
if (strict) {
|
|
48
|
+
throw new Error(`[rundot-syncplay] simulationEntries path does not exist: "${entry}" (resolved to ${resolved}).`);
|
|
49
|
+
}
|
|
50
|
+
warnLog(`[rundot-syncplay] skipping missing simulationEntries path: "${entry}" (resolved to ${resolved})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (resolvedEntries.length === 0) {
|
|
54
|
+
warnLog('[rundot-syncplay] every simulationEntries path was missing — determinism checking is OFF for this build');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const diagnostics = await checkDeterminism({
|
|
58
|
+
paths: resolvedEntries,
|
|
59
|
+
mode,
|
|
60
|
+
});
|
|
61
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error');
|
|
62
|
+
const warnings = diagnostics.filter((diagnostic) => diagnostic.severity !== 'error');
|
|
63
|
+
if (warnings.length > 0) {
|
|
64
|
+
warnLog(formatDiagnostics(warnings));
|
|
65
|
+
}
|
|
66
|
+
// error-class rules (Math.random, Date.now, timers, fetch, dynamic
|
|
67
|
+
// import) fail the build in EVERY mode — no report-only path for them.
|
|
68
|
+
if (errors.length > 0) {
|
|
69
|
+
throw new Error(formatDiagnostics(errors));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function createSyncplayViteConfig(options) {
|
|
77
|
+
return {
|
|
78
|
+
plugins: [rundotSyncplayPlugin(options)],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function withSyncplayViteConfig(config, options) {
|
|
82
|
+
return {
|
|
83
|
+
...config,
|
|
84
|
+
plugins: [
|
|
85
|
+
...(config.plugins ?? []),
|
|
86
|
+
rundotSyncplayPlugin(options),
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function formatDiagnostics(diagnostics) {
|
|
91
|
+
return diagnostics
|
|
92
|
+
.map((diagnostic) => {
|
|
93
|
+
return [
|
|
94
|
+
`${diagnostic.ruleId} ${diagnostic.severity.toUpperCase()} ${diagnostic.path}:${diagnostic.line}:${diagnostic.column}`,
|
|
95
|
+
diagnostic.message,
|
|
96
|
+
`Use: ${diagnostic.suggestion}`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
})
|
|
99
|
+
.join('\n\n');
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@series-inc/rundot-syncplay",
|
|
3
|
-
"version": "5.25.0-beta.
|
|
3
|
+
"version": "5.25.0-beta.3",
|
|
4
4
|
"description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -49,8 +49,9 @@
|
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
51
51
|
"test": "npm run test:core",
|
|
52
|
-
"test:core": "tsx --test tests/canonical.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts",
|
|
52
|
+
"test:core": "tsx --test tests/canonical.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/test-manifest.test.ts tests/coarse-region.test.ts tests/run-glue-surface.test.mjs",
|
|
53
53
|
"test:soak": "tsx --test tests/runtime-soak.test.ts",
|
|
54
|
+
"test:extended": "tsx --test tests/animation.test.ts tests/asset-cooking.test.ts tests/backend-proof-checksum-vectors.test.ts tests/bot-documents.test.ts tests/bot-primitives.test.ts tests/collider-cooking.test.ts tests/collision.test.ts tests/command-timeline.test.ts tests/config-bundle.test.ts tests/cosmetic-physics3d.test.ts tests/deterministic-primitives.test.ts tests/ecs-lite.test.ts tests/effects-adapter.test.ts tests/engine-runtime.test.ts tests/errors.test.ts tests/events.test.ts tests/input-button.test.ts tests/load-128-players.test.ts tests/math.test.ts tests/movement.test.ts tests/movement3d.test.ts tests/multiclient-authority.test.ts tests/navigation.test.ts tests/noise.test.ts tests/physics-cert.test.ts tests/physics2d.test.ts tests/physics3d-ccd.test.ts tests/physics3d-coverage.test.ts tests/physics3d-destruction-cert.test.ts tests/physics3d-heightfield.test.ts tests/physics3d-hull.test.ts tests/physics3d-joints.test.ts tests/physics3d-kinematic-mesh.test.ts tests/physics3d-mass.test.ts tests/physics3d-materials.test.ts tests/physics3d-mesh-onesided.test.ts tests/physics3d-query-index.test.ts tests/physics3d-recipes.test.ts tests/physics3d-rotation-convention.test.ts tests/physics3d-shared.test.ts tests/physics3d-sleeping-manifold-reuse.test.ts tests/physics3d-solver.test.ts tests/physics3d-stacking.test.ts tests/physics3d-toi.test.ts tests/physics3d-vehicle.test.ts tests/physics3d-voxel.test.ts tests/physics3d-world-edit.test.ts tests/physics3d.test.ts tests/random.test.ts tests/rollback-history.test.ts tests/schema-artifacts.test.ts tests/schema-codegen.test.ts tests/schema-dsl-binary.test.ts tests/schema.test.ts tests/signals.test.ts tests/sparse-set.test.ts tests/static-checker.test.ts tests/system-lifecycle.test.ts tests/wire-canonical-sentinels.test.ts tests/ws-frame.test.ts",
|
|
54
55
|
"test:ci": "npm run build && npm run test:core",
|
|
55
56
|
"pack:dry-run": "npm pack --dry-run"
|
|
56
57
|
},
|
|
@@ -64,7 +65,8 @@
|
|
|
64
65
|
"Kinetix"
|
|
65
66
|
],
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"@series-inc/rundot-
|
|
68
|
+
"@series-inc/rundot-game-sdk": "5.25.0-beta.1",
|
|
69
|
+
"@series-inc/rundot-kinetix": "0.1.0-beta.6"
|
|
68
70
|
},
|
|
69
71
|
"devDependencies": {
|
|
70
72
|
"@dimforge/rapier2d-compat": "0.19.3",
|