@genex-ai/multiplayer 0.8.8 → 0.10.0
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 +68 -19
- package/dist/index.d.ts +41 -6
- package/dist/index.js +300 -15
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +4 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,13 +13,13 @@ import { connect } from '@genex-ai/multiplayer';
|
|
|
13
13
|
## Connect
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
|
|
16
|
+
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
|
|
17
17
|
|
|
18
18
|
type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
|
|
19
19
|
|
|
20
20
|
await waitForPlayer(); // identity gate — resolves for guests AND accounts
|
|
21
21
|
const room = await connect<State>({
|
|
22
|
-
|
|
22
|
+
urls: getColyseusUrls(), // regional relays for this session — SDK joins the fastest
|
|
23
23
|
room: '<game-id>', // everyone with the same id shares a room (use the project slug)
|
|
24
24
|
name: 'ada', // optional display name
|
|
25
25
|
auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
|
|
@@ -41,6 +41,7 @@ Replaces your state wholesale. Call on a **fixed tick (~10–20Hz)**, not every
|
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
43
|
room.me.set({ x, y, z });
|
|
44
|
+
room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-reset remote history
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
## `players` — everyone in the room
|
|
@@ -74,21 +75,57 @@ everyone else reads it **auto-smoothed**, on the same interpolation as remote pl
|
|
|
74
75
|
survives the owner leaving — the object is reassigned to the room host.
|
|
75
76
|
|
|
76
77
|
```ts
|
|
77
|
-
|
|
78
|
+
// Legacy fire-and-forget claim: optimistic locally, corrected by the relay if rejected.
|
|
79
|
+
if (iTouched) room.objects.claim('ball');
|
|
80
|
+
|
|
81
|
+
// Irreversible action: wait for the authoritative result.
|
|
82
|
+
const result = await room.objects.claimConfirmed('ball');
|
|
83
|
+
if (result.accepted) applyKick();
|
|
84
|
+
else if (result.reason === 'held') retryWhileStillTouching(result.retryAfterMs);
|
|
78
85
|
|
|
79
86
|
if (room.objects.get('ball')?.isMine) {
|
|
80
87
|
room.objects.set('ball', simulateBall()); // only the owner's writes land
|
|
81
88
|
}
|
|
82
|
-
const ball = room.objects.get('ball'); // { owner, isMine, state, stateRaw }
|
|
89
|
+
const ball = room.objects.get('ball'); // { owner, isMine, epoch, state, stateRaw }
|
|
83
90
|
drawBall(ball.state); // smoothed for everyone (live for the owner)
|
|
84
91
|
|
|
85
92
|
room.objects.release('ball'); // give it up (stays until reclaimed)
|
|
86
93
|
room.objects.remove('bullet'); // destroy transient objects when done
|
|
94
|
+
|
|
95
|
+
await room.objects.releaseConfirmed('ball'); // acknowledged + idempotent under reconnect
|
|
96
|
+
await room.objects.removeConfirmed('bullet'); // acknowledged owner-only removal
|
|
97
|
+
|
|
98
|
+
// Deliberate discontinuity: one hard reseed, never an old-to-new interpolation sweep.
|
|
99
|
+
room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });
|
|
87
100
|
```
|
|
88
101
|
|
|
89
102
|
Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
|
|
90
103
|
player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.ids()` lists them.
|
|
91
104
|
|
|
105
|
+
The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
|
|
106
|
+
reassert is a true no-op: it does not extend that hold. Legacy `claim()` remains optimistic for
|
|
107
|
+
0.8.x compatibility; use the confirmed methods when feedback, impulses, seats, scoring, release,
|
|
108
|
+
or removal must not lie. Confirmed requests have stable request ids internally and are resent after
|
|
109
|
+
a reconnect, so an accepted operation is idempotent. A rejected result reports the current owner and,
|
|
110
|
+
for `held`, `retryAfterMs`.
|
|
111
|
+
|
|
112
|
+
A `timeout` result means the outcome is **unknown**, not "failed": the relay may have applied the
|
|
113
|
+
operation and only the answer was lost. Shared owner truth (`objects.get(id)?.isMine` /
|
|
114
|
+
`owner`) converges through the state mirror regardless — treat it, not the timed-out promise, as
|
|
115
|
+
authoritative before tearing anything down. On a relay that predates protocol v2, `me.snap` /
|
|
116
|
+
`objects.snap` degrade gracefully: the discontinuity write itself is not stored (peers converge on
|
|
117
|
+
your next ordinary `set`, without the history reset) — which is why the relay must be upgraded
|
|
118
|
+
before games ship an SDK that relies on snaps (see the migration note).
|
|
119
|
+
|
|
120
|
+
`claimConfirmed(id)` is an ordinary fair player claim, including when the caller happens to be host.
|
|
121
|
+
Only host-owned lifecycle work such as initial seeding or a goal reset should request
|
|
122
|
+
`claimConfirmed(id, { authority: 'host' })`; the relay accepts that bypass only from the current host.
|
|
123
|
+
When it actually bypasses an active hold the result reason is `host-authority`; a non-host request is
|
|
124
|
+
rejected as `not-host`. Do not use host authority for ordinary host-player collisions.
|
|
125
|
+
|
|
126
|
+
`me.snap` and `objects.snap` are for discontinuities only: respawn, teleport, reset, or a player mode
|
|
127
|
+
edge such as exiting a vehicle. Ordinary movement must use `set`, otherwise motion will visibly snap.
|
|
128
|
+
|
|
92
129
|
## `isHost` / `host` — the elected room authority
|
|
93
130
|
|
|
94
131
|
One client is the **host** — the earliest **signed-in** player still present (guests host only
|
|
@@ -124,14 +161,19 @@ room.on('disconnect', (code) => {}); // terminal — connect() fresh to
|
|
|
124
161
|
|
|
125
162
|
## Reconnection (automatic)
|
|
126
163
|
|
|
127
|
-
A dropped socket (Wi-Fi blip, brief signal loss) is **not** a leave: the relay holds your
|
|
128
|
-
seat for a grace window (~30s) and the SDK reconnects with backoff.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
164
|
+
A dropped socket (Wi-Fi blip, brief signal loss) is **not** immediately a leave: the relay holds your
|
|
165
|
+
seat for a grace window (~30s) and the SDK reconnects with backoff. A short blip keeps the same session
|
|
166
|
+
id, owned objects, and host status. Simulation availability is a separate lease: if the disconnected
|
|
167
|
+
seat is host for longer than the relay's short host-failover grace, an active peer becomes host and
|
|
168
|
+
host-owned objects move once. A returning old host keeps its seat but does not steal authority back.
|
|
169
|
+
Host-tick callbacks pause while their socket is reconnecting and resume only if that session still owns
|
|
170
|
+
the host role. After a long reconnect, remote interpolation histories are rebased to current truth so
|
|
171
|
+
they cannot draw a whole-map catch-up streak.
|
|
172
|
+
|
|
173
|
+
Render `reconnecting`/`reconnected` as an overlay and keep your loop running. `disconnect` is terminal
|
|
174
|
+
(server restart, revoked session, or the link never recovered): read a fresh token and `connect()`
|
|
175
|
+
again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
|
|
176
|
+
a deploy — flush saves when you see it.
|
|
135
177
|
|
|
136
178
|
**One seat per player:** the relay enforces a single session per identity per room — a
|
|
137
179
|
second tab / device / reloaded page supersedes the previous session (old one gets
|
|
@@ -160,8 +202,10 @@ room.onHostTick(30, (dtMs) => { // runs only on t
|
|
|
160
202
|
});
|
|
161
203
|
```
|
|
162
204
|
|
|
163
|
-
`onHostTick` auto-starts on election
|
|
164
|
-
|
|
205
|
+
`onHostTick` auto-starts on election, pauses during reconnect, resumes after reconnect only if still
|
|
206
|
+
host, and stops on demotion, deliberate `leave()`, or terminal disconnect. The disposer is still useful
|
|
207
|
+
when the game removes that subsystem earlier. A new host must confirm an explicit host-authority claim
|
|
208
|
+
and seed its world from each object's `stateRaw`, so simulation survives host migration without a snap.
|
|
165
209
|
|
|
166
210
|
## `send` — custom one-off events
|
|
167
211
|
|
|
@@ -183,6 +227,11 @@ room.leave();
|
|
|
183
227
|
|
|
184
228
|
Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the **sender's clock** (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay **self-tunes to both the live snapshot rate and the link's measured jitter**, so it stays smooth whether you send at 5Hz or 30Hz and whether the connection is a clean LAN or a jittery mobile link — a deeper buffer is used only where the jitter demands it.
|
|
185
229
|
|
|
230
|
+
Do not add another lerp, snapshot buffer, or remote physics predictor on top. `state` is already the
|
|
231
|
+
render value. This relay does not implement classic client-side prediction plus server reconciliation:
|
|
232
|
+
your own player is client-authoritative and renders locally; remote entities use interpolation and
|
|
233
|
+
bounded extrapolation.
|
|
234
|
+
|
|
186
235
|
- `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
|
|
187
236
|
- **Your own player is never delayed** — you always render yourself live.
|
|
188
237
|
- **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
|
|
@@ -197,12 +246,12 @@ Remote players are **smoothed automatically** — nothing to configure. `room.pl
|
|
|
197
246
|
|
|
198
247
|
```ts
|
|
199
248
|
import { connect } from '@genex-ai/multiplayer';
|
|
200
|
-
import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
|
|
249
|
+
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
|
|
201
250
|
type S = { x: number; z: number };
|
|
202
251
|
|
|
203
252
|
await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
|
|
204
253
|
const room = await connect<S>({
|
|
205
|
-
|
|
254
|
+
urls: getColyseusUrls(),
|
|
206
255
|
room: GAME_ID,
|
|
207
256
|
auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected
|
|
208
257
|
});
|
|
@@ -240,7 +289,7 @@ import { matchmake } from "@genex-ai/multiplayer";
|
|
|
240
289
|
|
|
241
290
|
// Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
|
|
242
291
|
// (~10 min); a function is read fresh each join, a static object goes stale mid-session.
|
|
243
|
-
const mm = await matchmake<State>({
|
|
292
|
+
const mm = await matchmake<State>({ urls, room: slug, auth: () => getColyseusAuth() });
|
|
244
293
|
|
|
245
294
|
// `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
|
|
246
295
|
// each frame:
|
|
@@ -295,11 +344,11 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
|
|
|
295
344
|
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
|
|
296
345
|
|
|
297
346
|
// host
|
|
298
|
-
const lobby = await createPrivate<State>({
|
|
347
|
+
const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
|
|
299
348
|
share(lobby.code); // e.g. show it in the UI
|
|
300
349
|
|
|
301
350
|
// friend (elsewhere)
|
|
302
|
-
const joined = await joinPrivate<State>(code, {
|
|
351
|
+
const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
|
|
303
352
|
|
|
304
353
|
// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
|
|
305
354
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ export { PlayerState, RoomState } from './schema.js';
|
|
|
2
2
|
import '@colyseus/schema';
|
|
3
3
|
|
|
4
4
|
type ConnectOptions = {
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
|
|
6
|
+
* lowest-latency one; a single entry just connects. */
|
|
7
|
+
urls: string[];
|
|
7
8
|
/** Game id — everyone who passes the same id shares a room; different ids are
|
|
8
9
|
* fully isolated. Use your project slug (or any stable per-game string). */
|
|
9
10
|
room: string;
|
|
@@ -37,6 +38,8 @@ type PlayerView<TState> = {
|
|
|
37
38
|
/** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
|
|
38
39
|
type ObjectView<TState> = {
|
|
39
40
|
id: string;
|
|
41
|
+
/** Relay-owned discontinuity epoch. Helpers direct-place physics when this changes. */
|
|
42
|
+
epoch: number;
|
|
40
43
|
/** Session id of the current owner, or `undefined` if unowned. */
|
|
41
44
|
owner: string | undefined;
|
|
42
45
|
/** True if you own it (so you should simulate + `objects.set` it). */
|
|
@@ -47,6 +50,28 @@ type ObjectView<TState> = {
|
|
|
47
50
|
/** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
|
|
48
51
|
stateRaw: TState;
|
|
49
52
|
};
|
|
53
|
+
type ObjectControlOperation = 'claim' | 'release' | 'remove';
|
|
54
|
+
type ObjectControlReason = 'accepted' | 'already-owner' | 'held' | 'rate-limited' | 'not-owner' | 'capacity' | 'invalid' | 'missing' | 'host-authority' | 'not-host' | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
|
|
55
|
+
type ObjectControlResult = {
|
|
56
|
+
requestId: string;
|
|
57
|
+
operation: ObjectControlOperation;
|
|
58
|
+
id: string;
|
|
59
|
+
accepted: boolean;
|
|
60
|
+
owner?: string;
|
|
61
|
+
sequence: number;
|
|
62
|
+
reason: ObjectControlReason;
|
|
63
|
+
retryAfterMs?: number;
|
|
64
|
+
};
|
|
65
|
+
type ObjectControlOptions = {
|
|
66
|
+
/** Ordinary player claims are subject to the hold. Host authority is for seeding/reset only. */
|
|
67
|
+
authority?: 'player' | 'host';
|
|
68
|
+
/** Locally mark a claim as owned while awaiting the result. Default false. */
|
|
69
|
+
optimistic?: boolean;
|
|
70
|
+
/** Maximum wait including reconnect resend. Default 5000 ms. */
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
/** Cancels the pending operation and resolves with reason `aborted`. */
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
};
|
|
50
75
|
type Session<TState extends Record<string, unknown>> = {
|
|
51
76
|
/** Your own session id. */
|
|
52
77
|
readonly id: string;
|
|
@@ -62,6 +87,8 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
62
87
|
* (~10–20Hz), not every render frame — each call sends one network message.
|
|
63
88
|
*/
|
|
64
89
|
set(state: TState): void;
|
|
90
|
+
/** Publish a deliberate discontinuity (respawn/teleport/mode edge) and clear remote history. */
|
|
91
|
+
snap(state: TState): void;
|
|
65
92
|
};
|
|
66
93
|
readonly shared: {
|
|
67
94
|
/** Read a shared value (any JSON), or `undefined`. */
|
|
@@ -79,14 +106,22 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
79
106
|
* Ownership survives an owner leaving: the object is reassigned to the room host.
|
|
80
107
|
*/
|
|
81
108
|
readonly objects: {
|
|
82
|
-
/**
|
|
109
|
+
/** Legacy optimistic ownership request. Prefer claimConfirmed for actions that need a result. */
|
|
83
110
|
claim(id: string): void;
|
|
111
|
+
/** Request ownership and receive the relay's accepted/rejected result. */
|
|
112
|
+
claimConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
84
113
|
/** Give up ownership (leaves the object unowned until someone claims it). */
|
|
85
114
|
release(id: string): void;
|
|
115
|
+
/** Give up ownership only after an explicit relay acknowledgement. */
|
|
116
|
+
releaseConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
86
117
|
/** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
|
|
87
118
|
remove(id: string): void;
|
|
119
|
+
/** Destroy the object and receive an idempotent relay acknowledgement. */
|
|
120
|
+
removeConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
88
121
|
/** Publish the object's state — only takes effect while you own it. Full object each call. */
|
|
89
122
|
set(id: string, state: Record<string, unknown>): void;
|
|
123
|
+
/** Owner-only deliberate discontinuity (reset/teleport), rendered as one hard reseed. */
|
|
124
|
+
snap(id: string, state: Record<string, unknown>): void;
|
|
90
125
|
/** Read an object, or `undefined` if it doesn't exist yet. */
|
|
91
126
|
get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
|
|
92
127
|
/** Ids of every object seen so far. */
|
|
@@ -230,8 +265,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
|
|
|
230
265
|
cancel(): void;
|
|
231
266
|
};
|
|
232
267
|
type MatchmakeOptions = {
|
|
233
|
-
/**
|
|
234
|
-
|
|
268
|
+
/** Regional relay urls (pass `getColyseusUrls()`) — pings each, connects to the lowest-latency one. */
|
|
269
|
+
urls: string[];
|
|
235
270
|
/** Game id (your project slug/id) — isolates this game's matchmaking. */
|
|
236
271
|
room: string;
|
|
237
272
|
name?: string;
|
|
@@ -276,4 +311,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
276
311
|
*/
|
|
277
312
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
278
313
|
|
|
279
|
-
export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
|
|
314
|
+
export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { Client, getStateCallbacks } from "colyseus
|
|
2
|
+
import { Client, getStateCallbacks } from "@colyseus/sdk";
|
|
3
3
|
var RELAY_ROOM_TYPE = "play";
|
|
4
4
|
var QUEUE_ROOM_TYPE = "queue";
|
|
5
5
|
var MATCH_ROOM_TYPE = "match";
|
|
6
6
|
var INTERP_RATIO = 1.5;
|
|
7
7
|
var INTERP_MIN_MS = 90;
|
|
8
8
|
var INTERP_MAX_MS = 300;
|
|
9
|
-
var JITTER_SAFETY = 1.
|
|
9
|
+
var JITTER_SAFETY = 1.25;
|
|
10
10
|
var POISON_GUARD_MS = 2e3;
|
|
11
11
|
var INTERP_EASE = 0.1;
|
|
12
12
|
var MAX_EXTRAP_MS = 250;
|
|
@@ -20,7 +20,19 @@ var OBJ_PREFIX = "__obj:";
|
|
|
20
20
|
var HOST_KEY = "__host";
|
|
21
21
|
var MATCH_KEY = "__match";
|
|
22
22
|
var INPUT_MSG = "__input";
|
|
23
|
-
var
|
|
23
|
+
var CAPABILITIES_MSG = "__capabilities";
|
|
24
|
+
var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
|
|
25
|
+
var OBJECT_CONTROL_RESULT_MSG = "__object:result";
|
|
26
|
+
var OBJECT_CONTROL_VERSION = 2;
|
|
27
|
+
var STATE_SNAP_MSG = "__state:snap";
|
|
28
|
+
var OBJECT_SNAP_MSG = "__object:snap";
|
|
29
|
+
var MP_PROTOCOL = 2;
|
|
30
|
+
async function relayClient(opts) {
|
|
31
|
+
if (opts.urls.length > 1) return Client.selectByLatency(opts.urls);
|
|
32
|
+
const url = opts.urls[0];
|
|
33
|
+
if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
|
|
34
|
+
return new Client(url);
|
|
35
|
+
}
|
|
24
36
|
function parseData(s) {
|
|
25
37
|
if (!s) return { state: {} };
|
|
26
38
|
try {
|
|
@@ -145,7 +157,7 @@ function quantile(xs, q) {
|
|
|
145
157
|
return s[i];
|
|
146
158
|
}
|
|
147
159
|
async function connect(opts) {
|
|
148
|
-
const client =
|
|
160
|
+
const client = await relayClient(opts);
|
|
149
161
|
const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
|
|
150
162
|
gameId: opts.room,
|
|
151
163
|
name: opts.name,
|
|
@@ -163,13 +175,18 @@ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set([
|
|
|
163
175
|
"obj",
|
|
164
176
|
"release",
|
|
165
177
|
"destroy",
|
|
178
|
+
STATE_SNAP_MSG,
|
|
179
|
+
OBJECT_SNAP_MSG,
|
|
166
180
|
// Server→client control verbs a game must never ORIGINATE — they'd broadcast to peers via the
|
|
167
181
|
// relay's `*` handler: 'server:restart' would trip every peer's save-flush, 'rate:drop' a bogus
|
|
168
182
|
// over-budget warning, 'queue'/'seat' spurious matchmaking control.
|
|
169
183
|
"server:restart",
|
|
170
184
|
"rate:drop",
|
|
171
185
|
"queue",
|
|
172
|
-
"seat"
|
|
186
|
+
"seat",
|
|
187
|
+
CAPABILITIES_MSG,
|
|
188
|
+
CAPABILITIES_REQUEST_MSG,
|
|
189
|
+
OBJECT_CONTROL_RESULT_MSG
|
|
173
190
|
]);
|
|
174
191
|
var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
|
|
175
192
|
var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
|
|
@@ -187,10 +204,14 @@ function attachSession(client, initialRoom) {
|
|
|
187
204
|
const autoDelayById = /* @__PURE__ */ new Map();
|
|
188
205
|
let localState;
|
|
189
206
|
const objOwners = /* @__PURE__ */ new Map();
|
|
207
|
+
const objEpochs = /* @__PURE__ */ new Map();
|
|
190
208
|
const objRaw = /* @__PURE__ */ new Map();
|
|
191
209
|
const localObjState = /* @__PURE__ */ new Map();
|
|
192
210
|
const pendingBridge = /* @__PURE__ */ new Map();
|
|
193
211
|
let hostId;
|
|
212
|
+
let objectControlVersion = 0;
|
|
213
|
+
let requestCounter = 0;
|
|
214
|
+
const pendingControls = /* @__PURE__ */ new Map();
|
|
194
215
|
const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
|
|
195
216
|
const now = testSkew ? () => nowMs() + testSkew : nowMs;
|
|
196
217
|
const pushSnap = (id, state, sendTime) => {
|
|
@@ -286,8 +307,13 @@ function attachSession(client, initialRoom) {
|
|
|
286
307
|
"disconnect",
|
|
287
308
|
INPUT_MSG,
|
|
288
309
|
// inputs.on() has its own channel — never bridged as a custom event
|
|
289
|
-
"rate:drop"
|
|
310
|
+
"rate:drop",
|
|
290
311
|
// relay → us only (over-budget warning); handled internally, never bridged
|
|
312
|
+
CAPABILITIES_MSG,
|
|
313
|
+
CAPABILITIES_REQUEST_MSG,
|
|
314
|
+
OBJECT_CONTROL_RESULT_MSG,
|
|
315
|
+
STATE_SNAP_MSG,
|
|
316
|
+
OBJECT_SNAP_MSG
|
|
291
317
|
]);
|
|
292
318
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
293
319
|
const lastShared = /* @__PURE__ */ new Map();
|
|
@@ -309,18 +335,26 @@ function attachSession(client, initialRoom) {
|
|
|
309
335
|
if (!blob || typeof blob !== "object") return;
|
|
310
336
|
const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
|
|
311
337
|
const ownerChanged = owner !== objOwners.get(id);
|
|
338
|
+
const epoch = typeof blob.e === "number" && Number.isFinite(blob.e) ? blob.e : 0;
|
|
339
|
+
const epochChanged = objEpochs.has(id) && epoch !== objEpochs.get(id);
|
|
340
|
+
objEpochs.set(id, epoch);
|
|
341
|
+
if (epochChanged) clearStream(key);
|
|
312
342
|
if (ownerChanged) {
|
|
313
343
|
if (owner) objOwners.set(id, owner);
|
|
314
344
|
else objOwners.delete(id);
|
|
315
345
|
if (owner !== room.sessionId) {
|
|
316
346
|
localObjState.delete(id);
|
|
317
|
-
clearStreamClock(key);
|
|
347
|
+
if (!epochChanged) clearStreamClock(key);
|
|
318
348
|
}
|
|
319
349
|
}
|
|
320
350
|
if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
|
|
321
351
|
objRaw.set(id, blob.d);
|
|
322
352
|
if (owner !== room.sessionId) {
|
|
323
|
-
if (
|
|
353
|
+
if (epochChanged) {
|
|
354
|
+
smoothed.set(key, blob.d);
|
|
355
|
+
smoothedAt.set(key, now());
|
|
356
|
+
pushSnap(key, blob.d, blob.t);
|
|
357
|
+
} else if (ownerChanged) {
|
|
324
358
|
pendingBridge.set(key, smoothed.get(key) ?? blob.d);
|
|
325
359
|
} else {
|
|
326
360
|
pushSnap(key, blob.d, blob.t);
|
|
@@ -333,6 +367,7 @@ function attachSession(client, initialRoom) {
|
|
|
333
367
|
emit("shared", key, parseValue(value));
|
|
334
368
|
};
|
|
335
369
|
const parseCache = /* @__PURE__ */ new Map();
|
|
370
|
+
const playerEpochs = /* @__PURE__ */ new Map();
|
|
336
371
|
const cachedParse = (id, raw) => {
|
|
337
372
|
if (!raw) return {};
|
|
338
373
|
const hit = parseCache.get(id);
|
|
@@ -343,6 +378,145 @@ function attachSession(client, initialRoom) {
|
|
|
343
378
|
};
|
|
344
379
|
let intentionalLeave = false;
|
|
345
380
|
let dead = false;
|
|
381
|
+
let connected = true;
|
|
382
|
+
let reconnectStartedAt = 0;
|
|
383
|
+
const hostTickSyncs = /* @__PURE__ */ new Set();
|
|
384
|
+
const syncHostTicks = () => hostTickSyncs.forEach((sync) => sync());
|
|
385
|
+
const settleControl = (pending, result) => {
|
|
386
|
+
pendingControls.delete(pending.requestId);
|
|
387
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
388
|
+
if (pending.capabilityTimer) clearTimeout(pending.capabilityTimer);
|
|
389
|
+
pending.abort?.();
|
|
390
|
+
if (pending.operation === "claim" && pending.optimistic && !result.accepted) {
|
|
391
|
+
if (result.owner) objOwners.set(pending.id, result.owner);
|
|
392
|
+
else objOwners.delete(pending.id);
|
|
393
|
+
if (result.owner !== room.sessionId) localObjState.delete(pending.id);
|
|
394
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
395
|
+
}
|
|
396
|
+
if (pending.operation === "claim" && result.accepted) {
|
|
397
|
+
objOwners.set(pending.id, room.sessionId);
|
|
398
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
399
|
+
}
|
|
400
|
+
if (pending.operation === "release" && result.accepted) {
|
|
401
|
+
localObjState.delete(pending.id);
|
|
402
|
+
if (objOwners.get(pending.id) === room.sessionId) objOwners.delete(pending.id);
|
|
403
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
404
|
+
}
|
|
405
|
+
if (pending.operation === "remove" && result.accepted) {
|
|
406
|
+
localObjState.delete(pending.id);
|
|
407
|
+
objRaw.delete(pending.id);
|
|
408
|
+
objOwners.delete(pending.id);
|
|
409
|
+
clearStream(OBJ_PREFIX + pending.id);
|
|
410
|
+
}
|
|
411
|
+
pending.resolve(result);
|
|
412
|
+
};
|
|
413
|
+
const settleAllControls = (reason) => {
|
|
414
|
+
for (const pending of [...pendingControls.values()]) {
|
|
415
|
+
settleControl(pending, {
|
|
416
|
+
requestId: pending.requestId,
|
|
417
|
+
operation: pending.operation,
|
|
418
|
+
id: pending.id,
|
|
419
|
+
accepted: false,
|
|
420
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(pending.id),
|
|
421
|
+
sequence: 0,
|
|
422
|
+
reason
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const sendPendingControl = (pending) => {
|
|
427
|
+
if (!connected || !room.connection?.isOpen) return;
|
|
428
|
+
send0(
|
|
429
|
+
pending.operation === "remove" ? "destroy" : pending.operation,
|
|
430
|
+
pending.payload
|
|
431
|
+
);
|
|
432
|
+
};
|
|
433
|
+
const flushCapabilityWaiters = () => {
|
|
434
|
+
if (objectControlVersion < OBJECT_CONTROL_VERSION) return;
|
|
435
|
+
for (const pending of pendingControls.values()) {
|
|
436
|
+
if (pending.capabilityTimer) {
|
|
437
|
+
clearTimeout(pending.capabilityTimer);
|
|
438
|
+
pending.capabilityTimer = null;
|
|
439
|
+
}
|
|
440
|
+
sendPendingControl(pending);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
const confirmedControl = (operation, id, options = {}) => {
|
|
444
|
+
const key = String(id);
|
|
445
|
+
const requestId = `${room.sessionId.slice(0, 12)}:${Date.now().toString(36)}:${(++requestCounter).toString(36)}`;
|
|
446
|
+
const timeoutMs = Math.max(250, Math.min(options.timeoutMs ?? 5e3, 6e4));
|
|
447
|
+
return new Promise((resolve) => {
|
|
448
|
+
const pending = {
|
|
449
|
+
operation,
|
|
450
|
+
id: key,
|
|
451
|
+
requestId,
|
|
452
|
+
payload: {
|
|
453
|
+
id: key,
|
|
454
|
+
requestId,
|
|
455
|
+
...options.authority === "host" ? { authority: "host" } : {}
|
|
456
|
+
},
|
|
457
|
+
optimistic: operation === "claim" && options.optimistic === true,
|
|
458
|
+
previousOwner: objOwners.get(key),
|
|
459
|
+
resolve,
|
|
460
|
+
timeout: null,
|
|
461
|
+
capabilityTimer: null,
|
|
462
|
+
abort: void 0
|
|
463
|
+
};
|
|
464
|
+
if (pending.optimistic) objOwners.set(key, room.sessionId);
|
|
465
|
+
pending.timeout = setTimeout(() => {
|
|
466
|
+
settleControl(pending, {
|
|
467
|
+
requestId,
|
|
468
|
+
operation,
|
|
469
|
+
id: key,
|
|
470
|
+
accepted: false,
|
|
471
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
472
|
+
sequence: 0,
|
|
473
|
+
reason: "timeout"
|
|
474
|
+
});
|
|
475
|
+
}, timeoutMs);
|
|
476
|
+
if (options.signal) {
|
|
477
|
+
const onAbort = () => settleControl(pending, {
|
|
478
|
+
requestId,
|
|
479
|
+
operation,
|
|
480
|
+
id: key,
|
|
481
|
+
accepted: false,
|
|
482
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
483
|
+
sequence: 0,
|
|
484
|
+
reason: "aborted"
|
|
485
|
+
});
|
|
486
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
487
|
+
pending.abort = () => options.signal?.removeEventListener("abort", onAbort);
|
|
488
|
+
}
|
|
489
|
+
pendingControls.set(requestId, pending);
|
|
490
|
+
if (options.signal?.aborted) {
|
|
491
|
+
settleControl(pending, {
|
|
492
|
+
requestId,
|
|
493
|
+
operation,
|
|
494
|
+
id: key,
|
|
495
|
+
accepted: false,
|
|
496
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
497
|
+
sequence: 0,
|
|
498
|
+
reason: "aborted"
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (objectControlVersion >= OBJECT_CONTROL_VERSION) {
|
|
503
|
+
sendPendingControl(pending);
|
|
504
|
+
} else {
|
|
505
|
+
pending.capabilityTimer = setTimeout(() => {
|
|
506
|
+
if (!pendingControls.has(requestId) || objectControlVersion >= OBJECT_CONTROL_VERSION) return;
|
|
507
|
+
settleControl(pending, {
|
|
508
|
+
requestId,
|
|
509
|
+
operation,
|
|
510
|
+
id: key,
|
|
511
|
+
accepted: false,
|
|
512
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
513
|
+
sequence: 0,
|
|
514
|
+
reason: "unsupported"
|
|
515
|
+
});
|
|
516
|
+
}, 500);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
};
|
|
346
520
|
let droppedWhileOffline = 0;
|
|
347
521
|
const send0 = (type, payload, countIfDropped = false) => {
|
|
348
522
|
if (room.connection?.isOpen) {
|
|
@@ -355,13 +529,22 @@ function attachSession(client, initialRoom) {
|
|
|
355
529
|
if (countIfDropped) droppedWhileOffline++;
|
|
356
530
|
};
|
|
357
531
|
const wireRoom = (r) => {
|
|
532
|
+
let hydrated;
|
|
358
533
|
const $ = getStateCallbacks(r);
|
|
359
534
|
$(r.state).players.onAdd((player, id) => {
|
|
360
535
|
const { state, sendTime } = parseData(player.data);
|
|
536
|
+
const addEpoch = Number.isFinite(player.epoch) ? player.epoch : 0;
|
|
537
|
+
const addEpochChanged = playerEpochs.has(id) && addEpoch !== playerEpochs.get(id);
|
|
538
|
+
playerEpochs.set(id, addEpoch);
|
|
539
|
+
if (id !== room.sessionId && addEpochChanged) clearStream(id);
|
|
361
540
|
emit("join", id, state);
|
|
362
541
|
if (id !== room.sessionId && sendTime != null) pushSnap(id, state, sendTime);
|
|
363
542
|
$(player).onChange(() => {
|
|
364
543
|
const { state: st, sendTime: ts } = parseData(player.data);
|
|
544
|
+
const epoch = Number.isFinite(player.epoch) ? player.epoch : 0;
|
|
545
|
+
const epochChanged = epoch !== (playerEpochs.get(id) ?? epoch);
|
|
546
|
+
playerEpochs.set(id, epoch);
|
|
547
|
+
if (id !== room.sessionId && epochChanged) clearStream(id);
|
|
365
548
|
emit("change", id, st);
|
|
366
549
|
if (id !== room.sessionId) pushSnap(id, st, ts);
|
|
367
550
|
});
|
|
@@ -370,6 +553,7 @@ function attachSession(client, initialRoom) {
|
|
|
370
553
|
emit("leave", id);
|
|
371
554
|
clearStream(id);
|
|
372
555
|
parseCache.delete(id);
|
|
556
|
+
playerEpochs.delete(id);
|
|
373
557
|
});
|
|
374
558
|
$(r.state).shared.onAdd(handleShared);
|
|
375
559
|
$(r.state).shared.onChange(handleShared);
|
|
@@ -387,9 +571,57 @@ function attachSession(client, initialRoom) {
|
|
|
387
571
|
clearStream(key);
|
|
388
572
|
objRaw.delete(id);
|
|
389
573
|
objOwners.delete(id);
|
|
574
|
+
objEpochs.delete(id);
|
|
575
|
+
localObjState.delete(id);
|
|
576
|
+
emit("object", id);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
const hydrateShared = (state) => {
|
|
580
|
+
const shared = state?.shared;
|
|
581
|
+
shared?.forEach((value, key) => handleShared(value, key));
|
|
582
|
+
if (!shared) return;
|
|
583
|
+
for (const id of /* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])) {
|
|
584
|
+
const key = OBJ_PREFIX + id;
|
|
585
|
+
if (shared.has(key)) continue;
|
|
586
|
+
lastShared.delete(key);
|
|
587
|
+
clearStream(key);
|
|
588
|
+
objRaw.delete(id);
|
|
589
|
+
objOwners.delete(id);
|
|
590
|
+
objEpochs.delete(id);
|
|
390
591
|
localObjState.delete(id);
|
|
391
592
|
emit("object", id);
|
|
392
593
|
}
|
|
594
|
+
if (hostId !== void 0 && !shared.has(HOST_KEY)) {
|
|
595
|
+
lastShared.delete(HOST_KEY);
|
|
596
|
+
hostId = void 0;
|
|
597
|
+
emit("host", void 0);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
if (r.state?.shared) {
|
|
601
|
+
hydrateShared(r.state);
|
|
602
|
+
hydrated = Promise.resolve();
|
|
603
|
+
} else {
|
|
604
|
+
hydrated = new Promise((resolve) => {
|
|
605
|
+
r.onStateChange.once((state) => {
|
|
606
|
+
hydrateShared(state);
|
|
607
|
+
resolve();
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
r.onMessage(CAPABILITIES_MSG, (msg) => {
|
|
612
|
+
if (typeof msg?.objectControl === "number") {
|
|
613
|
+
objectControlVersion = Math.max(objectControlVersion, msg.objectControl);
|
|
614
|
+
flushCapabilityWaiters();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
|
|
618
|
+
});
|
|
619
|
+
send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
|
|
620
|
+
r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
|
|
621
|
+
if (!msg || typeof msg.requestId !== "string") return;
|
|
622
|
+
const pending = pendingControls.get(msg.requestId);
|
|
623
|
+
if (!pending || msg.operation !== pending.operation || msg.id !== pending.id) return;
|
|
624
|
+
settleControl(pending, msg);
|
|
393
625
|
});
|
|
394
626
|
r.onMessage(INPUT_MSG, (msg) => {
|
|
395
627
|
if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
|
|
@@ -403,12 +635,17 @@ function attachSession(client, initialRoom) {
|
|
|
403
635
|
r.onLeave((code) => handleLeave(r, code ?? 1006));
|
|
404
636
|
r.onError(() => {
|
|
405
637
|
});
|
|
638
|
+
return hydrated;
|
|
406
639
|
};
|
|
407
640
|
const handleLeave = (left, code) => {
|
|
408
641
|
if (left !== room || dead) return;
|
|
642
|
+
connected = false;
|
|
643
|
+
reconnectStartedAt ||= nowMs();
|
|
644
|
+
syncHostTicks();
|
|
409
645
|
if (intentionalLeave) return;
|
|
410
646
|
if (code === 1e3 || code >= 4e3) {
|
|
411
647
|
dead = true;
|
|
648
|
+
settleAllControls("disconnected");
|
|
412
649
|
emit("disconnect", code);
|
|
413
650
|
return;
|
|
414
651
|
}
|
|
@@ -421,7 +658,17 @@ function attachSession(client, initialRoom) {
|
|
|
421
658
|
try {
|
|
422
659
|
const next = await client.reconnect(token);
|
|
423
660
|
room = next;
|
|
424
|
-
|
|
661
|
+
connected = true;
|
|
662
|
+
const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
|
|
663
|
+
reconnectStartedAt = 0;
|
|
664
|
+
if (reconnectGap > 1e3) {
|
|
665
|
+
for (const streamKey of [...buffers.keys()]) clearStream(streamKey);
|
|
666
|
+
}
|
|
667
|
+
const hydrated = wireRoom(next);
|
|
668
|
+
await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
|
|
669
|
+
if (room !== next || dead || intentionalLeave) return;
|
|
670
|
+
syncHostTicks();
|
|
671
|
+
for (const pending of pendingControls.values()) sendPendingControl(pending);
|
|
425
672
|
emit("reconnected");
|
|
426
673
|
if (droppedWhileOffline > 0) {
|
|
427
674
|
console.warn(
|
|
@@ -438,6 +685,7 @@ function attachSession(client, initialRoom) {
|
|
|
438
685
|
}
|
|
439
686
|
}
|
|
440
687
|
dead = true;
|
|
688
|
+
settleAllControls("disconnected");
|
|
441
689
|
emit("disconnect", code);
|
|
442
690
|
};
|
|
443
691
|
wireRoom(room);
|
|
@@ -482,6 +730,14 @@ function attachSession(client, initialRoom) {
|
|
|
482
730
|
const clean = sanitizeFinite(state);
|
|
483
731
|
localState = clean;
|
|
484
732
|
send0("state", JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
733
|
+
},
|
|
734
|
+
snap(state) {
|
|
735
|
+
if (SEND_TIME_KEY in state) {
|
|
736
|
+
console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
|
|
737
|
+
}
|
|
738
|
+
const clean = sanitizeFinite(state);
|
|
739
|
+
localState = clean;
|
|
740
|
+
send0(STATE_SNAP_MSG, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
485
741
|
}
|
|
486
742
|
},
|
|
487
743
|
shared: {
|
|
@@ -503,6 +759,9 @@ function attachSession(client, initialRoom) {
|
|
|
503
759
|
objOwners.set(key, room.sessionId);
|
|
504
760
|
send0("claim", { id: key });
|
|
505
761
|
},
|
|
762
|
+
claimConfirmed(id, options) {
|
|
763
|
+
return confirmedControl("claim", id, options);
|
|
764
|
+
},
|
|
506
765
|
release(id) {
|
|
507
766
|
const key = String(id);
|
|
508
767
|
localObjState.delete(key);
|
|
@@ -510,6 +769,9 @@ function attachSession(client, initialRoom) {
|
|
|
510
769
|
clearStreamClock(OBJ_PREFIX + key);
|
|
511
770
|
send0("release", { id: key });
|
|
512
771
|
},
|
|
772
|
+
releaseConfirmed(id, options) {
|
|
773
|
+
return confirmedControl("release", id, options);
|
|
774
|
+
},
|
|
513
775
|
remove(id) {
|
|
514
776
|
const key = String(id);
|
|
515
777
|
if (objOwners.get(key) === room.sessionId) {
|
|
@@ -520,12 +782,21 @@ function attachSession(client, initialRoom) {
|
|
|
520
782
|
}
|
|
521
783
|
send0("destroy", { id: key });
|
|
522
784
|
},
|
|
785
|
+
removeConfirmed(id, options) {
|
|
786
|
+
return confirmedControl("remove", id, options);
|
|
787
|
+
},
|
|
523
788
|
set(id, state) {
|
|
524
789
|
const key = String(id);
|
|
525
790
|
const clean = sanitizeFinite(state);
|
|
526
791
|
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
527
792
|
send0("obj", { id: key, d: clean, t: now() });
|
|
528
793
|
},
|
|
794
|
+
snap(id, state) {
|
|
795
|
+
const key = String(id);
|
|
796
|
+
const clean = sanitizeFinite(state);
|
|
797
|
+
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
798
|
+
send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
|
|
799
|
+
},
|
|
529
800
|
get(id) {
|
|
530
801
|
const key = String(id);
|
|
531
802
|
const owner = objOwners.get(key);
|
|
@@ -543,7 +814,14 @@ function attachSession(client, initialRoom) {
|
|
|
543
814
|
raw = objRaw.get(key) ?? {};
|
|
544
815
|
state = sampleSmoothed(OBJ_PREFIX + key, now()) ?? raw;
|
|
545
816
|
}
|
|
546
|
-
return {
|
|
817
|
+
return {
|
|
818
|
+
id: key,
|
|
819
|
+
epoch: objEpochs.get(key) ?? 0,
|
|
820
|
+
owner,
|
|
821
|
+
isMine,
|
|
822
|
+
state,
|
|
823
|
+
stateRaw: raw
|
|
824
|
+
};
|
|
547
825
|
},
|
|
548
826
|
ids() {
|
|
549
827
|
return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
|
|
@@ -570,24 +848,28 @@ function attachSession(client, initialRoom) {
|
|
|
570
848
|
onHostTick(hz, cb) {
|
|
571
849
|
let timer = null;
|
|
572
850
|
let last = 0;
|
|
851
|
+
const safeHz = Number.isFinite(hz) && hz > 0 ? Math.min(hz, 100) : 20;
|
|
852
|
+
const intervalMs = Math.max(10, Math.round(1e3 / safeHz));
|
|
573
853
|
const sync = () => {
|
|
574
|
-
const shouldRun = hostId !== void 0 && hostId === room.sessionId;
|
|
854
|
+
const shouldRun = connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
|
|
575
855
|
if (shouldRun && !timer) {
|
|
576
856
|
last = nowMs();
|
|
577
857
|
timer = setInterval(() => {
|
|
578
858
|
const t = nowMs();
|
|
579
859
|
cb(Math.min(t - last, 250));
|
|
580
860
|
last = t;
|
|
581
|
-
},
|
|
861
|
+
}, intervalMs);
|
|
582
862
|
} else if (!shouldRun && timer) {
|
|
583
863
|
clearInterval(timer);
|
|
584
864
|
timer = null;
|
|
585
865
|
}
|
|
586
866
|
};
|
|
587
867
|
const off = on("host", sync);
|
|
868
|
+
hostTickSyncs.add(sync);
|
|
588
869
|
sync();
|
|
589
870
|
return () => {
|
|
590
871
|
off();
|
|
872
|
+
hostTickSyncs.delete(sync);
|
|
591
873
|
if (timer) clearInterval(timer);
|
|
592
874
|
timer = null;
|
|
593
875
|
};
|
|
@@ -602,6 +884,9 @@ function attachSession(client, initialRoom) {
|
|
|
602
884
|
},
|
|
603
885
|
leave() {
|
|
604
886
|
intentionalLeave = true;
|
|
887
|
+
connected = false;
|
|
888
|
+
syncHostTicks();
|
|
889
|
+
settleAllControls("aborted");
|
|
605
890
|
void room.leave();
|
|
606
891
|
}
|
|
607
892
|
};
|
|
@@ -668,7 +953,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
668
953
|
};
|
|
669
954
|
}
|
|
670
955
|
async function matchmake(opts) {
|
|
671
|
-
const client =
|
|
956
|
+
const client = await relayClient(opts);
|
|
672
957
|
const emitters = /* @__PURE__ */ new Map();
|
|
673
958
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
674
959
|
const on = ((event, cb) => {
|
|
@@ -849,14 +1134,14 @@ function wirePrivate(client, room) {
|
|
|
849
1134
|
};
|
|
850
1135
|
}
|
|
851
1136
|
async function createPrivate(opts) {
|
|
852
|
-
const client =
|
|
1137
|
+
const client = await relayClient(opts);
|
|
853
1138
|
const room = await client.create(MATCH_ROOM_TYPE, matchJoinOptions(opts));
|
|
854
1139
|
const handle = wirePrivate(client, room);
|
|
855
1140
|
Object.defineProperty(handle, "code", { value: room.roomId, enumerable: true });
|
|
856
1141
|
return handle;
|
|
857
1142
|
}
|
|
858
1143
|
async function joinPrivate(code, opts) {
|
|
859
|
-
const client =
|
|
1144
|
+
const client = await relayClient(opts);
|
|
860
1145
|
const room = await client.joinById(code, matchJoinOptions(opts));
|
|
861
1146
|
return wirePrivate(client, room);
|
|
862
1147
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { SchemaType, MapSchema } from '@colyseus/schema';
|
|
|
4
4
|
declare const PlayerState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
5
5
|
name: "string";
|
|
6
6
|
data: "string";
|
|
7
|
+
epoch: "number";
|
|
7
8
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
8
9
|
name: "string";
|
|
9
10
|
data: "string";
|
|
11
|
+
epoch: "number";
|
|
10
12
|
}>>, typeof _colyseus_schema.Schema>;
|
|
11
13
|
type PlayerState = SchemaType<typeof PlayerState>;
|
|
12
14
|
declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
@@ -14,13 +16,16 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
|
14
16
|
map: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
15
17
|
name: "string";
|
|
16
18
|
data: "string";
|
|
19
|
+
epoch: "number";
|
|
17
20
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
18
21
|
name: "string";
|
|
19
22
|
data: "string";
|
|
23
|
+
epoch: "number";
|
|
20
24
|
}>>, typeof _colyseus_schema.Schema>;
|
|
21
25
|
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
22
26
|
name: "string";
|
|
23
27
|
data: "string";
|
|
28
|
+
epoch: "number";
|
|
24
29
|
}>, string>;
|
|
25
30
|
};
|
|
26
31
|
shared: {
|
|
@@ -32,13 +37,16 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
|
32
37
|
map: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
33
38
|
name: "string";
|
|
34
39
|
data: "string";
|
|
40
|
+
epoch: "number";
|
|
35
41
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
36
42
|
name: "string";
|
|
37
43
|
data: "string";
|
|
44
|
+
epoch: "number";
|
|
38
45
|
}>>, typeof _colyseus_schema.Schema>;
|
|
39
46
|
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
40
47
|
name: "string";
|
|
41
48
|
data: "string";
|
|
49
|
+
epoch: "number";
|
|
42
50
|
}>, string>;
|
|
43
51
|
};
|
|
44
52
|
shared: {
|
package/dist/schema.js
CHANGED
|
@@ -3,7 +3,10 @@ import { schema, MapSchema } from "@colyseus/schema";
|
|
|
3
3
|
var PlayerState = schema(
|
|
4
4
|
{
|
|
5
5
|
name: "string",
|
|
6
|
-
data: "string"
|
|
6
|
+
data: "string",
|
|
7
|
+
// Relay-owned discontinuity epoch. Ordinary state updates keep it stable;
|
|
8
|
+
// state:snap increments it so new SDKs discard interpolation history.
|
|
9
|
+
epoch: "number"
|
|
7
10
|
},
|
|
8
11
|
"PlayerState"
|
|
9
12
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@colyseus/schema": "^
|
|
34
|
-
"colyseus
|
|
33
|
+
"@colyseus/schema": "^4.0.27",
|
|
34
|
+
"@colyseus/sdk": "^0.17.43"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"three": ">=0.160.0"
|