@genex-ai/multiplayer 0.8.7 → 0.9.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 -16
- package/dist/index.d.ts +36 -2
- package/dist/index.js +289 -10
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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.
|
|
@@ -250,7 +299,8 @@ else renderSearchingHud(mm.matchmaking);
|
|
|
250
299
|
mm.on("matched", () => {/* session is live */});
|
|
251
300
|
mm.on("matchStart", ({ roundId }) => {/* round began */});
|
|
252
301
|
mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
|
|
253
|
-
mm.on("spectate", () => {/*
|
|
302
|
+
mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side spectators anymore.
|
|
303
|
+
An overflow searcher now waits in the queue instead of watching. */});
|
|
254
304
|
mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
|
|
255
305
|
(broken auth / server unreachable); fix the cause, then mm.retry() */});
|
|
256
306
|
|
|
@@ -267,7 +317,7 @@ A failed queue join is **retried automatically** with backoff (~10s total), so t
|
|
|
267
317
|
(a token mid-rotation, identity still settling on page load) never surface. Only a persistent
|
|
268
318
|
failure emits `error`; the handle then idles until you call `mm.retry()`.
|
|
269
319
|
|
|
270
|
-
**`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended
|
|
320
|
+
**`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended`; the legacy `spectating` is never emitted — server-side spectators were removed), `queue` (`{ position, size }`), `players`, `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases.
|
|
271
321
|
|
|
272
322
|
Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
|
|
273
323
|
removing it clears the stored config on the next preview/publish — never sent from the client):
|
|
@@ -275,14 +325,16 @@ removing it clears the stored config on the next preview/publish — never sent
|
|
|
275
325
|
```jsonc
|
|
276
326
|
"genex": {
|
|
277
327
|
"matchmaking": {
|
|
278
|
-
"preset": "arena", // duel | arena | teams | private
|
|
328
|
+
"preset": "arena", // open | duel | arena | teams | private
|
|
279
329
|
"winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
|
|
280
330
|
"config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
|
|
281
331
|
}
|
|
282
332
|
}
|
|
283
333
|
```
|
|
284
334
|
|
|
285
|
-
Presets: **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced N-v-N), **private** (invite-code lobby). Win conditions: **lastStanding**, **firstToScore**, **highScoreInTime**, **firstToFinish**; a round that hits the time cap undecided is a draw.
|
|
335
|
+
Presets: **open** (a room of N players, your rules — see below), **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced N-v-N), **private** (invite-code lobby). Win conditions (for the batteries-included presets): **lastStanding**, **firstToScore**, **highScoreInTime**, **firstToFinish**; a round that hits the time cap undecided is a draw.
|
|
336
|
+
|
|
337
|
+
**`open` — bring your own rules.** The minimal preset: the server owns only seating, capacity, and refill; you build teams, rounds, scoring, and win logic yourself on `session.shared` + host election. It runs no match loop, so `status` only goes `waiting`→`playing` (never `ended`), `scores`/`winnerId` stay empty, and **nobody is ever evicted** (no `matchEnded`, no requeue). Config knobs: `minPlayers` (quorum to flip to `playing`; default 1), `maxPlayers` (seat cap, clamped 2–64), `fill` (`1` = keep the room full — grow to max and refill a seat a leaver frees, the default; `0` = lock the room for good once it is simultaneously full (max players at once) — a match with no substitutes; a freed seat stays closed). Pick `open` when you want your own rules; pick duel/arena/teams for a ready-made match loop.
|
|
286
338
|
|
|
287
339
|
### Private lobbies — `createPrivate` / `joinPrivate`
|
|
288
340
|
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ type PlayerView<TState> = {
|
|
|
37
37
|
/** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
|
|
38
38
|
type ObjectView<TState> = {
|
|
39
39
|
id: string;
|
|
40
|
+
/** Relay-owned discontinuity epoch. Helpers direct-place physics when this changes. */
|
|
41
|
+
epoch: number;
|
|
40
42
|
/** Session id of the current owner, or `undefined` if unowned. */
|
|
41
43
|
owner: string | undefined;
|
|
42
44
|
/** True if you own it (so you should simulate + `objects.set` it). */
|
|
@@ -47,6 +49,28 @@ type ObjectView<TState> = {
|
|
|
47
49
|
/** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
|
|
48
50
|
stateRaw: TState;
|
|
49
51
|
};
|
|
52
|
+
type ObjectControlOperation = 'claim' | 'release' | 'remove';
|
|
53
|
+
type ObjectControlReason = 'accepted' | 'already-owner' | 'held' | 'rate-limited' | 'not-owner' | 'capacity' | 'invalid' | 'missing' | 'host-authority' | 'not-host' | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
|
|
54
|
+
type ObjectControlResult = {
|
|
55
|
+
requestId: string;
|
|
56
|
+
operation: ObjectControlOperation;
|
|
57
|
+
id: string;
|
|
58
|
+
accepted: boolean;
|
|
59
|
+
owner?: string;
|
|
60
|
+
sequence: number;
|
|
61
|
+
reason: ObjectControlReason;
|
|
62
|
+
retryAfterMs?: number;
|
|
63
|
+
};
|
|
64
|
+
type ObjectControlOptions = {
|
|
65
|
+
/** Ordinary player claims are subject to the hold. Host authority is for seeding/reset only. */
|
|
66
|
+
authority?: 'player' | 'host';
|
|
67
|
+
/** Locally mark a claim as owned while awaiting the result. Default false. */
|
|
68
|
+
optimistic?: boolean;
|
|
69
|
+
/** Maximum wait including reconnect resend. Default 5000 ms. */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/** Cancels the pending operation and resolves with reason `aborted`. */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
};
|
|
50
74
|
type Session<TState extends Record<string, unknown>> = {
|
|
51
75
|
/** Your own session id. */
|
|
52
76
|
readonly id: string;
|
|
@@ -62,6 +86,8 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
62
86
|
* (~10–20Hz), not every render frame — each call sends one network message.
|
|
63
87
|
*/
|
|
64
88
|
set(state: TState): void;
|
|
89
|
+
/** Publish a deliberate discontinuity (respawn/teleport/mode edge) and clear remote history. */
|
|
90
|
+
snap(state: TState): void;
|
|
65
91
|
};
|
|
66
92
|
readonly shared: {
|
|
67
93
|
/** Read a shared value (any JSON), or `undefined`. */
|
|
@@ -79,14 +105,22 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
79
105
|
* Ownership survives an owner leaving: the object is reassigned to the room host.
|
|
80
106
|
*/
|
|
81
107
|
readonly objects: {
|
|
82
|
-
/**
|
|
108
|
+
/** Legacy optimistic ownership request. Prefer claimConfirmed for actions that need a result. */
|
|
83
109
|
claim(id: string): void;
|
|
110
|
+
/** Request ownership and receive the relay's accepted/rejected result. */
|
|
111
|
+
claimConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
84
112
|
/** Give up ownership (leaves the object unowned until someone claims it). */
|
|
85
113
|
release(id: string): void;
|
|
114
|
+
/** Give up ownership only after an explicit relay acknowledgement. */
|
|
115
|
+
releaseConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
86
116
|
/** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
|
|
87
117
|
remove(id: string): void;
|
|
118
|
+
/** Destroy the object and receive an idempotent relay acknowledgement. */
|
|
119
|
+
removeConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
|
|
88
120
|
/** Publish the object's state — only takes effect while you own it. Full object each call. */
|
|
89
121
|
set(id: string, state: Record<string, unknown>): void;
|
|
122
|
+
/** Owner-only deliberate discontinuity (reset/teleport), rendered as one hard reseed. */
|
|
123
|
+
snap(id: string, state: Record<string, unknown>): void;
|
|
90
124
|
/** Read an object, or `undefined` if it doesn't exist yet. */
|
|
91
125
|
get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
|
|
92
126
|
/** Ids of every object seen so far. */
|
|
@@ -276,4 +310,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
276
310
|
*/
|
|
277
311
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
278
312
|
|
|
279
|
-
export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
|
|
313
|
+
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
|
@@ -6,7 +6,7 @@ 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,13 @@ 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;
|
|
24
30
|
function parseData(s) {
|
|
25
31
|
if (!s) return { state: {} };
|
|
26
32
|
try {
|
|
@@ -163,13 +169,18 @@ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set([
|
|
|
163
169
|
"obj",
|
|
164
170
|
"release",
|
|
165
171
|
"destroy",
|
|
172
|
+
STATE_SNAP_MSG,
|
|
173
|
+
OBJECT_SNAP_MSG,
|
|
166
174
|
// Server→client control verbs a game must never ORIGINATE — they'd broadcast to peers via the
|
|
167
175
|
// relay's `*` handler: 'server:restart' would trip every peer's save-flush, 'rate:drop' a bogus
|
|
168
176
|
// over-budget warning, 'queue'/'seat' spurious matchmaking control.
|
|
169
177
|
"server:restart",
|
|
170
178
|
"rate:drop",
|
|
171
179
|
"queue",
|
|
172
|
-
"seat"
|
|
180
|
+
"seat",
|
|
181
|
+
CAPABILITIES_MSG,
|
|
182
|
+
CAPABILITIES_REQUEST_MSG,
|
|
183
|
+
OBJECT_CONTROL_RESULT_MSG
|
|
173
184
|
]);
|
|
174
185
|
var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
|
|
175
186
|
var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
|
|
@@ -187,10 +198,14 @@ function attachSession(client, initialRoom) {
|
|
|
187
198
|
const autoDelayById = /* @__PURE__ */ new Map();
|
|
188
199
|
let localState;
|
|
189
200
|
const objOwners = /* @__PURE__ */ new Map();
|
|
201
|
+
const objEpochs = /* @__PURE__ */ new Map();
|
|
190
202
|
const objRaw = /* @__PURE__ */ new Map();
|
|
191
203
|
const localObjState = /* @__PURE__ */ new Map();
|
|
192
204
|
const pendingBridge = /* @__PURE__ */ new Map();
|
|
193
205
|
let hostId;
|
|
206
|
+
let objectControlVersion = 0;
|
|
207
|
+
let requestCounter = 0;
|
|
208
|
+
const pendingControls = /* @__PURE__ */ new Map();
|
|
194
209
|
const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
|
|
195
210
|
const now = testSkew ? () => nowMs() + testSkew : nowMs;
|
|
196
211
|
const pushSnap = (id, state, sendTime) => {
|
|
@@ -286,8 +301,13 @@ function attachSession(client, initialRoom) {
|
|
|
286
301
|
"disconnect",
|
|
287
302
|
INPUT_MSG,
|
|
288
303
|
// inputs.on() has its own channel — never bridged as a custom event
|
|
289
|
-
"rate:drop"
|
|
304
|
+
"rate:drop",
|
|
290
305
|
// relay → us only (over-budget warning); handled internally, never bridged
|
|
306
|
+
CAPABILITIES_MSG,
|
|
307
|
+
CAPABILITIES_REQUEST_MSG,
|
|
308
|
+
OBJECT_CONTROL_RESULT_MSG,
|
|
309
|
+
STATE_SNAP_MSG,
|
|
310
|
+
OBJECT_SNAP_MSG
|
|
291
311
|
]);
|
|
292
312
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
293
313
|
const lastShared = /* @__PURE__ */ new Map();
|
|
@@ -309,18 +329,26 @@ function attachSession(client, initialRoom) {
|
|
|
309
329
|
if (!blob || typeof blob !== "object") return;
|
|
310
330
|
const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
|
|
311
331
|
const ownerChanged = owner !== objOwners.get(id);
|
|
332
|
+
const epoch = typeof blob.e === "number" && Number.isFinite(blob.e) ? blob.e : 0;
|
|
333
|
+
const epochChanged = objEpochs.has(id) && epoch !== objEpochs.get(id);
|
|
334
|
+
objEpochs.set(id, epoch);
|
|
335
|
+
if (epochChanged) clearStream(key);
|
|
312
336
|
if (ownerChanged) {
|
|
313
337
|
if (owner) objOwners.set(id, owner);
|
|
314
338
|
else objOwners.delete(id);
|
|
315
339
|
if (owner !== room.sessionId) {
|
|
316
340
|
localObjState.delete(id);
|
|
317
|
-
clearStreamClock(key);
|
|
341
|
+
if (!epochChanged) clearStreamClock(key);
|
|
318
342
|
}
|
|
319
343
|
}
|
|
320
344
|
if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
|
|
321
345
|
objRaw.set(id, blob.d);
|
|
322
346
|
if (owner !== room.sessionId) {
|
|
323
|
-
if (
|
|
347
|
+
if (epochChanged) {
|
|
348
|
+
smoothed.set(key, blob.d);
|
|
349
|
+
smoothedAt.set(key, now());
|
|
350
|
+
pushSnap(key, blob.d, blob.t);
|
|
351
|
+
} else if (ownerChanged) {
|
|
324
352
|
pendingBridge.set(key, smoothed.get(key) ?? blob.d);
|
|
325
353
|
} else {
|
|
326
354
|
pushSnap(key, blob.d, blob.t);
|
|
@@ -333,6 +361,7 @@ function attachSession(client, initialRoom) {
|
|
|
333
361
|
emit("shared", key, parseValue(value));
|
|
334
362
|
};
|
|
335
363
|
const parseCache = /* @__PURE__ */ new Map();
|
|
364
|
+
const playerEpochs = /* @__PURE__ */ new Map();
|
|
336
365
|
const cachedParse = (id, raw) => {
|
|
337
366
|
if (!raw) return {};
|
|
338
367
|
const hit = parseCache.get(id);
|
|
@@ -343,6 +372,145 @@ function attachSession(client, initialRoom) {
|
|
|
343
372
|
};
|
|
344
373
|
let intentionalLeave = false;
|
|
345
374
|
let dead = false;
|
|
375
|
+
let connected = true;
|
|
376
|
+
let reconnectStartedAt = 0;
|
|
377
|
+
const hostTickSyncs = /* @__PURE__ */ new Set();
|
|
378
|
+
const syncHostTicks = () => hostTickSyncs.forEach((sync) => sync());
|
|
379
|
+
const settleControl = (pending, result) => {
|
|
380
|
+
pendingControls.delete(pending.requestId);
|
|
381
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
382
|
+
if (pending.capabilityTimer) clearTimeout(pending.capabilityTimer);
|
|
383
|
+
pending.abort?.();
|
|
384
|
+
if (pending.operation === "claim" && pending.optimistic && !result.accepted) {
|
|
385
|
+
if (result.owner) objOwners.set(pending.id, result.owner);
|
|
386
|
+
else objOwners.delete(pending.id);
|
|
387
|
+
if (result.owner !== room.sessionId) localObjState.delete(pending.id);
|
|
388
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
389
|
+
}
|
|
390
|
+
if (pending.operation === "claim" && result.accepted) {
|
|
391
|
+
objOwners.set(pending.id, room.sessionId);
|
|
392
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
393
|
+
}
|
|
394
|
+
if (pending.operation === "release" && result.accepted) {
|
|
395
|
+
localObjState.delete(pending.id);
|
|
396
|
+
if (objOwners.get(pending.id) === room.sessionId) objOwners.delete(pending.id);
|
|
397
|
+
clearStreamClock(OBJ_PREFIX + pending.id);
|
|
398
|
+
}
|
|
399
|
+
if (pending.operation === "remove" && result.accepted) {
|
|
400
|
+
localObjState.delete(pending.id);
|
|
401
|
+
objRaw.delete(pending.id);
|
|
402
|
+
objOwners.delete(pending.id);
|
|
403
|
+
clearStream(OBJ_PREFIX + pending.id);
|
|
404
|
+
}
|
|
405
|
+
pending.resolve(result);
|
|
406
|
+
};
|
|
407
|
+
const settleAllControls = (reason) => {
|
|
408
|
+
for (const pending of [...pendingControls.values()]) {
|
|
409
|
+
settleControl(pending, {
|
|
410
|
+
requestId: pending.requestId,
|
|
411
|
+
operation: pending.operation,
|
|
412
|
+
id: pending.id,
|
|
413
|
+
accepted: false,
|
|
414
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(pending.id),
|
|
415
|
+
sequence: 0,
|
|
416
|
+
reason
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
const sendPendingControl = (pending) => {
|
|
421
|
+
if (!connected || !room.connection?.isOpen) return;
|
|
422
|
+
send0(
|
|
423
|
+
pending.operation === "remove" ? "destroy" : pending.operation,
|
|
424
|
+
pending.payload
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
const flushCapabilityWaiters = () => {
|
|
428
|
+
if (objectControlVersion < OBJECT_CONTROL_VERSION) return;
|
|
429
|
+
for (const pending of pendingControls.values()) {
|
|
430
|
+
if (pending.capabilityTimer) {
|
|
431
|
+
clearTimeout(pending.capabilityTimer);
|
|
432
|
+
pending.capabilityTimer = null;
|
|
433
|
+
}
|
|
434
|
+
sendPendingControl(pending);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const confirmedControl = (operation, id, options = {}) => {
|
|
438
|
+
const key = String(id);
|
|
439
|
+
const requestId = `${room.sessionId.slice(0, 12)}:${Date.now().toString(36)}:${(++requestCounter).toString(36)}`;
|
|
440
|
+
const timeoutMs = Math.max(250, Math.min(options.timeoutMs ?? 5e3, 6e4));
|
|
441
|
+
return new Promise((resolve) => {
|
|
442
|
+
const pending = {
|
|
443
|
+
operation,
|
|
444
|
+
id: key,
|
|
445
|
+
requestId,
|
|
446
|
+
payload: {
|
|
447
|
+
id: key,
|
|
448
|
+
requestId,
|
|
449
|
+
...options.authority === "host" ? { authority: "host" } : {}
|
|
450
|
+
},
|
|
451
|
+
optimistic: operation === "claim" && options.optimistic === true,
|
|
452
|
+
previousOwner: objOwners.get(key),
|
|
453
|
+
resolve,
|
|
454
|
+
timeout: null,
|
|
455
|
+
capabilityTimer: null,
|
|
456
|
+
abort: void 0
|
|
457
|
+
};
|
|
458
|
+
if (pending.optimistic) objOwners.set(key, room.sessionId);
|
|
459
|
+
pending.timeout = setTimeout(() => {
|
|
460
|
+
settleControl(pending, {
|
|
461
|
+
requestId,
|
|
462
|
+
operation,
|
|
463
|
+
id: key,
|
|
464
|
+
accepted: false,
|
|
465
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
466
|
+
sequence: 0,
|
|
467
|
+
reason: "timeout"
|
|
468
|
+
});
|
|
469
|
+
}, timeoutMs);
|
|
470
|
+
if (options.signal) {
|
|
471
|
+
const onAbort = () => settleControl(pending, {
|
|
472
|
+
requestId,
|
|
473
|
+
operation,
|
|
474
|
+
id: key,
|
|
475
|
+
accepted: false,
|
|
476
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
477
|
+
sequence: 0,
|
|
478
|
+
reason: "aborted"
|
|
479
|
+
});
|
|
480
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
481
|
+
pending.abort = () => options.signal?.removeEventListener("abort", onAbort);
|
|
482
|
+
}
|
|
483
|
+
pendingControls.set(requestId, pending);
|
|
484
|
+
if (options.signal?.aborted) {
|
|
485
|
+
settleControl(pending, {
|
|
486
|
+
requestId,
|
|
487
|
+
operation,
|
|
488
|
+
id: key,
|
|
489
|
+
accepted: false,
|
|
490
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
491
|
+
sequence: 0,
|
|
492
|
+
reason: "aborted"
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (objectControlVersion >= OBJECT_CONTROL_VERSION) {
|
|
497
|
+
sendPendingControl(pending);
|
|
498
|
+
} else {
|
|
499
|
+
pending.capabilityTimer = setTimeout(() => {
|
|
500
|
+
if (!pendingControls.has(requestId) || objectControlVersion >= OBJECT_CONTROL_VERSION) return;
|
|
501
|
+
settleControl(pending, {
|
|
502
|
+
requestId,
|
|
503
|
+
operation,
|
|
504
|
+
id: key,
|
|
505
|
+
accepted: false,
|
|
506
|
+
owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
|
|
507
|
+
sequence: 0,
|
|
508
|
+
reason: "unsupported"
|
|
509
|
+
});
|
|
510
|
+
}, 500);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
};
|
|
346
514
|
let droppedWhileOffline = 0;
|
|
347
515
|
const send0 = (type, payload, countIfDropped = false) => {
|
|
348
516
|
if (room.connection?.isOpen) {
|
|
@@ -355,13 +523,22 @@ function attachSession(client, initialRoom) {
|
|
|
355
523
|
if (countIfDropped) droppedWhileOffline++;
|
|
356
524
|
};
|
|
357
525
|
const wireRoom = (r) => {
|
|
526
|
+
let hydrated;
|
|
358
527
|
const $ = getStateCallbacks(r);
|
|
359
528
|
$(r.state).players.onAdd((player, id) => {
|
|
360
529
|
const { state, sendTime } = parseData(player.data);
|
|
530
|
+
const addEpoch = Number.isFinite(player.epoch) ? player.epoch : 0;
|
|
531
|
+
const addEpochChanged = playerEpochs.has(id) && addEpoch !== playerEpochs.get(id);
|
|
532
|
+
playerEpochs.set(id, addEpoch);
|
|
533
|
+
if (id !== room.sessionId && addEpochChanged) clearStream(id);
|
|
361
534
|
emit("join", id, state);
|
|
362
535
|
if (id !== room.sessionId && sendTime != null) pushSnap(id, state, sendTime);
|
|
363
536
|
$(player).onChange(() => {
|
|
364
537
|
const { state: st, sendTime: ts } = parseData(player.data);
|
|
538
|
+
const epoch = Number.isFinite(player.epoch) ? player.epoch : 0;
|
|
539
|
+
const epochChanged = epoch !== (playerEpochs.get(id) ?? epoch);
|
|
540
|
+
playerEpochs.set(id, epoch);
|
|
541
|
+
if (id !== room.sessionId && epochChanged) clearStream(id);
|
|
365
542
|
emit("change", id, st);
|
|
366
543
|
if (id !== room.sessionId) pushSnap(id, st, ts);
|
|
367
544
|
});
|
|
@@ -370,6 +547,7 @@ function attachSession(client, initialRoom) {
|
|
|
370
547
|
emit("leave", id);
|
|
371
548
|
clearStream(id);
|
|
372
549
|
parseCache.delete(id);
|
|
550
|
+
playerEpochs.delete(id);
|
|
373
551
|
});
|
|
374
552
|
$(r.state).shared.onAdd(handleShared);
|
|
375
553
|
$(r.state).shared.onChange(handleShared);
|
|
@@ -387,10 +565,58 @@ function attachSession(client, initialRoom) {
|
|
|
387
565
|
clearStream(key);
|
|
388
566
|
objRaw.delete(id);
|
|
389
567
|
objOwners.delete(id);
|
|
568
|
+
objEpochs.delete(id);
|
|
390
569
|
localObjState.delete(id);
|
|
391
570
|
emit("object", id);
|
|
392
571
|
}
|
|
393
572
|
});
|
|
573
|
+
const hydrateShared = (state) => {
|
|
574
|
+
const shared = state?.shared;
|
|
575
|
+
shared?.forEach((value, key) => handleShared(value, key));
|
|
576
|
+
if (!shared) return;
|
|
577
|
+
for (const id of /* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])) {
|
|
578
|
+
const key = OBJ_PREFIX + id;
|
|
579
|
+
if (shared.has(key)) continue;
|
|
580
|
+
lastShared.delete(key);
|
|
581
|
+
clearStream(key);
|
|
582
|
+
objRaw.delete(id);
|
|
583
|
+
objOwners.delete(id);
|
|
584
|
+
objEpochs.delete(id);
|
|
585
|
+
localObjState.delete(id);
|
|
586
|
+
emit("object", id);
|
|
587
|
+
}
|
|
588
|
+
if (hostId !== void 0 && !shared.has(HOST_KEY)) {
|
|
589
|
+
lastShared.delete(HOST_KEY);
|
|
590
|
+
hostId = void 0;
|
|
591
|
+
emit("host", void 0);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
if (r.state?.shared) {
|
|
595
|
+
hydrateShared(r.state);
|
|
596
|
+
hydrated = Promise.resolve();
|
|
597
|
+
} else {
|
|
598
|
+
hydrated = new Promise((resolve) => {
|
|
599
|
+
r.onStateChange.once((state) => {
|
|
600
|
+
hydrateShared(state);
|
|
601
|
+
resolve();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
r.onMessage(CAPABILITIES_MSG, (msg) => {
|
|
606
|
+
if (typeof msg?.objectControl === "number") {
|
|
607
|
+
objectControlVersion = Math.max(objectControlVersion, msg.objectControl);
|
|
608
|
+
flushCapabilityWaiters();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
|
|
612
|
+
});
|
|
613
|
+
send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
|
|
614
|
+
r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
|
|
615
|
+
if (!msg || typeof msg.requestId !== "string") return;
|
|
616
|
+
const pending = pendingControls.get(msg.requestId);
|
|
617
|
+
if (!pending || msg.operation !== pending.operation || msg.id !== pending.id) return;
|
|
618
|
+
settleControl(pending, msg);
|
|
619
|
+
});
|
|
394
620
|
r.onMessage(INPUT_MSG, (msg) => {
|
|
395
621
|
if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
|
|
396
622
|
});
|
|
@@ -403,12 +629,17 @@ function attachSession(client, initialRoom) {
|
|
|
403
629
|
r.onLeave((code) => handleLeave(r, code ?? 1006));
|
|
404
630
|
r.onError(() => {
|
|
405
631
|
});
|
|
632
|
+
return hydrated;
|
|
406
633
|
};
|
|
407
634
|
const handleLeave = (left, code) => {
|
|
408
635
|
if (left !== room || dead) return;
|
|
636
|
+
connected = false;
|
|
637
|
+
reconnectStartedAt ||= nowMs();
|
|
638
|
+
syncHostTicks();
|
|
409
639
|
if (intentionalLeave) return;
|
|
410
640
|
if (code === 1e3 || code >= 4e3) {
|
|
411
641
|
dead = true;
|
|
642
|
+
settleAllControls("disconnected");
|
|
412
643
|
emit("disconnect", code);
|
|
413
644
|
return;
|
|
414
645
|
}
|
|
@@ -421,7 +652,17 @@ function attachSession(client, initialRoom) {
|
|
|
421
652
|
try {
|
|
422
653
|
const next = await client.reconnect(token);
|
|
423
654
|
room = next;
|
|
424
|
-
|
|
655
|
+
connected = true;
|
|
656
|
+
const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
|
|
657
|
+
reconnectStartedAt = 0;
|
|
658
|
+
if (reconnectGap > 1e3) {
|
|
659
|
+
for (const streamKey of [...buffers.keys()]) clearStream(streamKey);
|
|
660
|
+
}
|
|
661
|
+
const hydrated = wireRoom(next);
|
|
662
|
+
await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
|
|
663
|
+
if (room !== next || dead || intentionalLeave) return;
|
|
664
|
+
syncHostTicks();
|
|
665
|
+
for (const pending of pendingControls.values()) sendPendingControl(pending);
|
|
425
666
|
emit("reconnected");
|
|
426
667
|
if (droppedWhileOffline > 0) {
|
|
427
668
|
console.warn(
|
|
@@ -438,6 +679,7 @@ function attachSession(client, initialRoom) {
|
|
|
438
679
|
}
|
|
439
680
|
}
|
|
440
681
|
dead = true;
|
|
682
|
+
settleAllControls("disconnected");
|
|
441
683
|
emit("disconnect", code);
|
|
442
684
|
};
|
|
443
685
|
wireRoom(room);
|
|
@@ -482,6 +724,14 @@ function attachSession(client, initialRoom) {
|
|
|
482
724
|
const clean = sanitizeFinite(state);
|
|
483
725
|
localState = clean;
|
|
484
726
|
send0("state", JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
727
|
+
},
|
|
728
|
+
snap(state) {
|
|
729
|
+
if (SEND_TIME_KEY in state) {
|
|
730
|
+
console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
|
|
731
|
+
}
|
|
732
|
+
const clean = sanitizeFinite(state);
|
|
733
|
+
localState = clean;
|
|
734
|
+
send0(STATE_SNAP_MSG, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
485
735
|
}
|
|
486
736
|
},
|
|
487
737
|
shared: {
|
|
@@ -503,6 +753,9 @@ function attachSession(client, initialRoom) {
|
|
|
503
753
|
objOwners.set(key, room.sessionId);
|
|
504
754
|
send0("claim", { id: key });
|
|
505
755
|
},
|
|
756
|
+
claimConfirmed(id, options) {
|
|
757
|
+
return confirmedControl("claim", id, options);
|
|
758
|
+
},
|
|
506
759
|
release(id) {
|
|
507
760
|
const key = String(id);
|
|
508
761
|
localObjState.delete(key);
|
|
@@ -510,6 +763,9 @@ function attachSession(client, initialRoom) {
|
|
|
510
763
|
clearStreamClock(OBJ_PREFIX + key);
|
|
511
764
|
send0("release", { id: key });
|
|
512
765
|
},
|
|
766
|
+
releaseConfirmed(id, options) {
|
|
767
|
+
return confirmedControl("release", id, options);
|
|
768
|
+
},
|
|
513
769
|
remove(id) {
|
|
514
770
|
const key = String(id);
|
|
515
771
|
if (objOwners.get(key) === room.sessionId) {
|
|
@@ -520,12 +776,21 @@ function attachSession(client, initialRoom) {
|
|
|
520
776
|
}
|
|
521
777
|
send0("destroy", { id: key });
|
|
522
778
|
},
|
|
779
|
+
removeConfirmed(id, options) {
|
|
780
|
+
return confirmedControl("remove", id, options);
|
|
781
|
+
},
|
|
523
782
|
set(id, state) {
|
|
524
783
|
const key = String(id);
|
|
525
784
|
const clean = sanitizeFinite(state);
|
|
526
785
|
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
527
786
|
send0("obj", { id: key, d: clean, t: now() });
|
|
528
787
|
},
|
|
788
|
+
snap(id, state) {
|
|
789
|
+
const key = String(id);
|
|
790
|
+
const clean = sanitizeFinite(state);
|
|
791
|
+
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
792
|
+
send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
|
|
793
|
+
},
|
|
529
794
|
get(id) {
|
|
530
795
|
const key = String(id);
|
|
531
796
|
const owner = objOwners.get(key);
|
|
@@ -543,7 +808,14 @@ function attachSession(client, initialRoom) {
|
|
|
543
808
|
raw = objRaw.get(key) ?? {};
|
|
544
809
|
state = sampleSmoothed(OBJ_PREFIX + key, now()) ?? raw;
|
|
545
810
|
}
|
|
546
|
-
return {
|
|
811
|
+
return {
|
|
812
|
+
id: key,
|
|
813
|
+
epoch: objEpochs.get(key) ?? 0,
|
|
814
|
+
owner,
|
|
815
|
+
isMine,
|
|
816
|
+
state,
|
|
817
|
+
stateRaw: raw
|
|
818
|
+
};
|
|
547
819
|
},
|
|
548
820
|
ids() {
|
|
549
821
|
return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
|
|
@@ -570,24 +842,28 @@ function attachSession(client, initialRoom) {
|
|
|
570
842
|
onHostTick(hz, cb) {
|
|
571
843
|
let timer = null;
|
|
572
844
|
let last = 0;
|
|
845
|
+
const safeHz = Number.isFinite(hz) && hz > 0 ? Math.min(hz, 100) : 20;
|
|
846
|
+
const intervalMs = Math.max(10, Math.round(1e3 / safeHz));
|
|
573
847
|
const sync = () => {
|
|
574
|
-
const shouldRun = hostId !== void 0 && hostId === room.sessionId;
|
|
848
|
+
const shouldRun = connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
|
|
575
849
|
if (shouldRun && !timer) {
|
|
576
850
|
last = nowMs();
|
|
577
851
|
timer = setInterval(() => {
|
|
578
852
|
const t = nowMs();
|
|
579
853
|
cb(Math.min(t - last, 250));
|
|
580
854
|
last = t;
|
|
581
|
-
},
|
|
855
|
+
}, intervalMs);
|
|
582
856
|
} else if (!shouldRun && timer) {
|
|
583
857
|
clearInterval(timer);
|
|
584
858
|
timer = null;
|
|
585
859
|
}
|
|
586
860
|
};
|
|
587
861
|
const off = on("host", sync);
|
|
862
|
+
hostTickSyncs.add(sync);
|
|
588
863
|
sync();
|
|
589
864
|
return () => {
|
|
590
865
|
off();
|
|
866
|
+
hostTickSyncs.delete(sync);
|
|
591
867
|
if (timer) clearInterval(timer);
|
|
592
868
|
timer = null;
|
|
593
869
|
};
|
|
@@ -602,6 +878,9 @@ function attachSession(client, initialRoom) {
|
|
|
602
878
|
},
|
|
603
879
|
leave() {
|
|
604
880
|
intentionalLeave = true;
|
|
881
|
+
connected = false;
|
|
882
|
+
syncHostTicks();
|
|
883
|
+
settleAllControls("aborted");
|
|
605
884
|
void room.leave();
|
|
606
885
|
}
|
|
607
886
|
};
|
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
|
);
|