@genex-ai/multiplayer 0.10.0 → 0.10.2
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 +19 -7
- package/dist/index.d.ts +22 -16
- package/dist/index.js +86 -18
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,17 +22,25 @@ 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
|
-
auth: getColyseusAuth()
|
|
25
|
+
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
|
|
26
26
|
});
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`auth` is **required** — the relay rejects tokenless joins. Get it from `@genex-ai/embed-sdk`:
|
|
30
30
|
`await waitForPlayer()` first (never `waitForAuth()` — that resolves only for signed-in accounts
|
|
31
|
-
and keeps guests out forever), then
|
|
32
|
-
tokens rotate every ~10 min, so never cache the object
|
|
31
|
+
and keeps guests out forever), then pass `() => getColyseusAuth()` so each explicit
|
|
32
|
+
`connect()` reads it fresh — tokens rotate every ~10 min, so never cache the object
|
|
33
|
+
across reconnects. Never log it.
|
|
33
34
|
|
|
34
35
|
Different `room` ids are **fully isolated** — players in game A never see game B.
|
|
35
36
|
|
|
37
|
+
`connect()` joins immediately. Use it for one ongoing drop-in world. If the game has a
|
|
38
|
+
Play/Online/Local/Bots menu, call it only after the online click and call `room.leave()` when the
|
|
39
|
+
player returns to the offline menu. Immediate connect + spawn is valid only when loading the game
|
|
40
|
+
already means joining the always-online world; do not put a fake Play screen in front of a player
|
|
41
|
+
who is already present. Use `matchmake()` below for fresh bounded sessions with queue/quorum/fair
|
|
42
|
+
start/backfill behavior.
|
|
43
|
+
|
|
36
44
|
---
|
|
37
45
|
|
|
38
46
|
## `me` — publish your own state
|
|
@@ -46,7 +54,7 @@ room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-res
|
|
|
46
54
|
|
|
47
55
|
## `players` — everyone in the room
|
|
48
56
|
|
|
49
|
-
A fresh `Map` each read; **includes you** (skip with `id === room.id`). For remote players `p.state` is **auto-smoothed** (interpolated); for yourself it's live. `p.stateRaw` is always the latest raw snapshot (use it for hit-tests / logic).
|
|
57
|
+
A fresh `Map` each read; **includes you** (skip with `id === room.id`). For remote players `p.state` is **auto-smoothed** (interpolated); for yourself it's live. `p.stateRaw` is always the latest raw snapshot (use it for hit-tests / logic). A reserved reconnecting seat stays in `players` with `p.connected === false`; use `room.activePlayers` when gameplay needs the live transport quorum.
|
|
50
58
|
|
|
51
59
|
```ts
|
|
52
60
|
for (const [id, p] of room.players) {
|
|
@@ -100,7 +108,11 @@ room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });
|
|
|
100
108
|
```
|
|
101
109
|
|
|
102
110
|
Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
|
|
103
|
-
player. `state` is smoothed; `stateRaw` is the raw latest
|
|
111
|
+
player. `state` is render-only and smoothed; `stateRaw` is the raw latest for every gameplay read
|
|
112
|
+
(hit-tests, reach, pickups, physics adoption, discrete values). `objects.ids()` lists them.
|
|
113
|
+
`objects.set()` and `objects.snap()` are owner-only: SDK 0.10.2+ ignores an unowned call and warns
|
|
114
|
+
once per object/operation. Await `claimConfirmed()` and check `accepted` before an irreversible
|
|
115
|
+
write; legacy `claim(); set()` remains valid because `claim()` is optimistic locally.
|
|
104
116
|
|
|
105
117
|
The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
|
|
106
118
|
reassert is a true no-op: it does not extend that hold. Legacy `claim()` remains optimistic for
|
|
@@ -253,7 +265,7 @@ await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect a
|
|
|
253
265
|
const room = await connect<S>({
|
|
254
266
|
urls: getColyseusUrls(),
|
|
255
267
|
room: GAME_ID,
|
|
256
|
-
auth: getColyseusAuth()
|
|
268
|
+
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
|
|
257
269
|
});
|
|
258
270
|
|
|
259
271
|
const me = { x: 0, z: 0 };
|
|
@@ -317,7 +329,7 @@ A failed queue join is **retried automatically** with backoff (~10s total), so t
|
|
|
317
329
|
(a token mid-rotation, identity still settling on page load) never surface. Only a persistent
|
|
318
330
|
failure emits `error`; the handle then idles until you call `mm.retry()`.
|
|
319
331
|
|
|
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
|
|
332
|
+
**`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` (reserved seats), `connectedPlayers` (live transports), `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases. During an automatic reconnect, the SDK retains the last coherent roster and match view until the replacement room hydrates, so status does not transiently regress to `waiting`.
|
|
321
333
|
|
|
322
334
|
Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
|
|
323
335
|
removing it clears the stored config on the next preview/publish — never sent from the client):
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { PlayerState, RoomState } from './schema.js';
|
|
2
2
|
import '@colyseus/schema';
|
|
3
3
|
|
|
4
|
+
type ColyseusAuth = {
|
|
5
|
+
embedToken: string;
|
|
6
|
+
};
|
|
7
|
+
type AuthSource = ColyseusAuth | (() => ColyseusAuth | undefined);
|
|
4
8
|
type ConnectOptions = {
|
|
5
9
|
/** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
|
|
6
10
|
* lowest-latency one; a single entry just connects. */
|
|
@@ -17,20 +21,22 @@ type ConnectOptions = {
|
|
|
17
21
|
* `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForPlayer()`
|
|
18
22
|
* (from `@genex-ai/embed-sdk`). NOT `waitForAuth()` — that resolves only for
|
|
19
23
|
* signed-in accounts and would keep guests out of multiplayer forever.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
24
|
+
* Prefer a supplier `() => getColyseusAuth()` so every explicit connect()
|
|
25
|
+
* call reads the current token (tokens rotate every ~10 min). A current
|
|
26
|
+
* value remains supported for backward compatibility. NEVER log it.
|
|
22
27
|
*/
|
|
23
|
-
auth?:
|
|
24
|
-
embedToken: string;
|
|
25
|
-
};
|
|
28
|
+
auth?: AuthSource;
|
|
26
29
|
/** Extra join options passed to the room. */
|
|
27
30
|
options?: Record<string, unknown>;
|
|
28
31
|
};
|
|
29
32
|
type PlayerView<TState> = {
|
|
30
33
|
id: string;
|
|
31
34
|
name: string;
|
|
32
|
-
/**
|
|
33
|
-
|
|
35
|
+
/** Transport is live now. False while this reserved seat is inside reconnect grace. */
|
|
36
|
+
connected: boolean;
|
|
37
|
+
/** RENDER ONLY — for remote players this is **auto-smoothed** (interpolated) and delayed;
|
|
38
|
+
* for yourself it's live. Numeric fields are lerped; everything else snaps. Use `stateRaw`
|
|
39
|
+
* for every gameplay read (hits, reach, pickups, flags, physics adoption). */
|
|
34
40
|
state: TState;
|
|
35
41
|
/** The latest raw snapshot (never smoothed/delayed) — use for hit-tests / logic. */
|
|
36
42
|
stateRaw: TState;
|
|
@@ -44,8 +50,8 @@ type ObjectView<TState> = {
|
|
|
44
50
|
owner: string | undefined;
|
|
45
51
|
/** True if you own it (so you should simulate + `objects.set` it). */
|
|
46
52
|
isMine: boolean;
|
|
47
|
-
/**
|
|
48
|
-
* live (your last `set`) when it's yours. */
|
|
53
|
+
/** RENDER ONLY — **auto-smoothed** and delayed like a remote player when someone else owns it;
|
|
54
|
+
* live (your last `set`) when it's yours. Use `stateRaw` for every gameplay read. */
|
|
49
55
|
state: TState;
|
|
50
56
|
/** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
|
|
51
57
|
stateRaw: TState;
|
|
@@ -81,6 +87,8 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
81
87
|
* `state` is auto-smoothed; read `stateRaw` for the raw latest value.
|
|
82
88
|
*/
|
|
83
89
|
readonly players: Map<string, PlayerView<TState>>;
|
|
90
|
+
/** Connected subset of `players`; excludes seats currently held only by reconnect grace. */
|
|
91
|
+
readonly activePlayers: Map<string, PlayerView<TState>>;
|
|
84
92
|
readonly me: {
|
|
85
93
|
/**
|
|
86
94
|
* Publish your synced state (replaces it wholesale). Call on a **fixed tick**
|
|
@@ -198,7 +206,7 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
198
206
|
* await waitForPlayer(); // identity gate (guest OR signed-in)
|
|
199
207
|
* const room = await connect<{ x: number; y: number; z: number }>({
|
|
200
208
|
* url: 'ws://localhost:2567', room: gameId, name: 'ada',
|
|
201
|
-
* auth: getColyseusAuth()
|
|
209
|
+
* auth: () => getColyseusAuth(), // REQUIRED — fresh on every explicit connect call
|
|
202
210
|
* });
|
|
203
211
|
* room.me.set({ x, y, z }); // typed + validated by the compiler
|
|
204
212
|
* room.players.get(id)?.state.x; // number — auto-smoothed for remote players
|
|
@@ -220,6 +228,8 @@ type MatchmakingView = {
|
|
|
220
228
|
};
|
|
221
229
|
/** Every active participant id (N-player: 1v1, FFA, teams). */
|
|
222
230
|
players: string[];
|
|
231
|
+
/** Participants whose transport is live now; graced seats remain only in `players`. */
|
|
232
|
+
connectedPlayers: string[];
|
|
223
233
|
/** The other active player id(s) — everyone in `players` except you. */
|
|
224
234
|
opponents: string[];
|
|
225
235
|
/** teamId -> member ids for team presets; empty for FFA + 1v1. */
|
|
@@ -276,11 +286,7 @@ type MatchmakeOptions = {
|
|
|
276
286
|
* requeue). Pass a FUNCTION — `() => genexEmbed.getColyseusAuth()` — so each (re)join reads a FRESH
|
|
277
287
|
* token; a static object is read only once and will be rejected once it expires.
|
|
278
288
|
*/
|
|
279
|
-
auth?:
|
|
280
|
-
embedToken: string;
|
|
281
|
-
} | (() => {
|
|
282
|
-
embedToken: string;
|
|
283
|
-
} | undefined);
|
|
289
|
+
auth?: AuthSource;
|
|
284
290
|
};
|
|
285
291
|
/**
|
|
286
292
|
* Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
|
|
@@ -311,4 +317,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
311
317
|
*/
|
|
312
318
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
313
319
|
|
|
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 };
|
|
320
|
+
export { type AuthSource, type ColyseusAuth, 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
|
@@ -33,6 +33,7 @@ async function relayClient(opts) {
|
|
|
33
33
|
if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
|
|
34
34
|
return new Client(url);
|
|
35
35
|
}
|
|
36
|
+
var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
|
|
36
37
|
function parseData(s) {
|
|
37
38
|
if (!s) return { state: {} };
|
|
38
39
|
try {
|
|
@@ -161,7 +162,7 @@ async function connect(opts) {
|
|
|
161
162
|
const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
|
|
162
163
|
gameId: opts.room,
|
|
163
164
|
name: opts.name,
|
|
164
|
-
embedToken: opts.auth?.embedToken,
|
|
165
|
+
embedToken: resolveAuth(opts.auth)?.embedToken,
|
|
165
166
|
...opts.options,
|
|
166
167
|
__v: MP_PROTOCOL
|
|
167
168
|
// tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
|
|
@@ -207,6 +208,7 @@ function attachSession(client, initialRoom) {
|
|
|
207
208
|
const objEpochs = /* @__PURE__ */ new Map();
|
|
208
209
|
const objRaw = /* @__PURE__ */ new Map();
|
|
209
210
|
const localObjState = /* @__PURE__ */ new Map();
|
|
211
|
+
const warnedUnownedWrites = /* @__PURE__ */ new Set();
|
|
210
212
|
const pendingBridge = /* @__PURE__ */ new Map();
|
|
211
213
|
let hostId;
|
|
212
214
|
let objectControlVersion = 0;
|
|
@@ -214,6 +216,17 @@ function attachSession(client, initialRoom) {
|
|
|
214
216
|
const pendingControls = /* @__PURE__ */ new Map();
|
|
215
217
|
const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
|
|
216
218
|
const now = testSkew ? () => nowMs() + testSkew : nowMs;
|
|
219
|
+
const rejectUnownedWrite = (operation, id) => {
|
|
220
|
+
if (objOwners.get(id) === room.sessionId) return false;
|
|
221
|
+
const warningKey = `${operation}:${id}`;
|
|
222
|
+
if (!warnedUnownedWrites.has(warningKey)) {
|
|
223
|
+
warnedUnownedWrites.add(warningKey);
|
|
224
|
+
console.warn(
|
|
225
|
+
`[multiplayer] objects.${operation}('${id}') ignored because this session does not own the object \u2014 await objects.claimConfirmed('${id}') and check accepted before publishing`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
};
|
|
217
230
|
const pushSnap = (id, state, sendTime) => {
|
|
218
231
|
const arrival = now();
|
|
219
232
|
const t = sendTime ?? arrival;
|
|
@@ -368,6 +381,7 @@ function attachSession(client, initialRoom) {
|
|
|
368
381
|
};
|
|
369
382
|
const parseCache = /* @__PURE__ */ new Map();
|
|
370
383
|
const playerEpochs = /* @__PURE__ */ new Map();
|
|
384
|
+
let lastPlayersView = /* @__PURE__ */ new Map();
|
|
371
385
|
const cachedParse = (id, raw) => {
|
|
372
386
|
if (!raw) return {};
|
|
373
387
|
const hit = parseCache.get(id);
|
|
@@ -658,7 +672,6 @@ function attachSession(client, initialRoom) {
|
|
|
658
672
|
try {
|
|
659
673
|
const next = await client.reconnect(token);
|
|
660
674
|
room = next;
|
|
661
|
-
connected = true;
|
|
662
675
|
const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
|
|
663
676
|
reconnectStartedAt = 0;
|
|
664
677
|
if (reconnectGap > 1e3) {
|
|
@@ -667,6 +680,7 @@ function attachSession(client, initialRoom) {
|
|
|
667
680
|
const hydrated = wireRoom(next);
|
|
668
681
|
await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
|
|
669
682
|
if (room !== next || dead || intentionalLeave) return;
|
|
683
|
+
connected = true;
|
|
670
684
|
syncHostTicks();
|
|
671
685
|
for (const pending of pendingControls.values()) sendPendingControl(pending);
|
|
672
686
|
emit("reconnected");
|
|
@@ -718,10 +732,22 @@ function attachSession(client, initialRoom) {
|
|
|
718
732
|
} else {
|
|
719
733
|
state = sampleSmoothed(id, localNow) ?? latest;
|
|
720
734
|
}
|
|
721
|
-
out.set(id, {
|
|
735
|
+
out.set(id, {
|
|
736
|
+
id,
|
|
737
|
+
name: p.name,
|
|
738
|
+
connected: p.connected !== false,
|
|
739
|
+
// old relay schemas did not carry the field
|
|
740
|
+
state,
|
|
741
|
+
stateRaw: raw
|
|
742
|
+
});
|
|
722
743
|
});
|
|
744
|
+
if (!connected && out.size === 0 && lastPlayersView.size > 0) return new Map(lastPlayersView);
|
|
745
|
+
lastPlayersView = out;
|
|
723
746
|
return out;
|
|
724
747
|
},
|
|
748
|
+
get activePlayers() {
|
|
749
|
+
return new Map([...session.players].filter(([, player]) => player.connected));
|
|
750
|
+
},
|
|
725
751
|
me: {
|
|
726
752
|
set(state) {
|
|
727
753
|
if (SEND_TIME_KEY in state) {
|
|
@@ -787,12 +813,14 @@ function attachSession(client, initialRoom) {
|
|
|
787
813
|
},
|
|
788
814
|
set(id, state) {
|
|
789
815
|
const key = String(id);
|
|
816
|
+
if (rejectUnownedWrite("set", key)) return;
|
|
790
817
|
const clean = sanitizeFinite(state);
|
|
791
818
|
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
792
819
|
send0("obj", { id: key, d: clean, t: now() });
|
|
793
820
|
},
|
|
794
821
|
snap(id, state) {
|
|
795
822
|
const key = String(id);
|
|
823
|
+
if (rejectUnownedWrite("snap", key)) return;
|
|
796
824
|
const clean = sanitizeFinite(state);
|
|
797
825
|
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
798
826
|
send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
|
|
@@ -894,7 +922,6 @@ function attachSession(client, initialRoom) {
|
|
|
894
922
|
return session;
|
|
895
923
|
}
|
|
896
924
|
var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
897
|
-
var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
|
|
898
925
|
var matchJoinOptions = (opts) => ({
|
|
899
926
|
gameId: opts.room,
|
|
900
927
|
name: opts.name,
|
|
@@ -911,6 +938,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
911
938
|
role: "player",
|
|
912
939
|
queue: queueInfo,
|
|
913
940
|
players: [],
|
|
941
|
+
connectedPlayers: [],
|
|
914
942
|
opponents: [],
|
|
915
943
|
teams: {},
|
|
916
944
|
myTeam: null,
|
|
@@ -929,6 +957,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
929
957
|
const challengerId = m.challengerId ?? null;
|
|
930
958
|
const teams = m.teams ?? {};
|
|
931
959
|
const players = m.players ?? [championId, challengerId].filter((x) => !!x);
|
|
960
|
+
const connectedPlayers = m.connectedPlayers ?? players;
|
|
932
961
|
const isPlayer = myId != null && players.includes(myId);
|
|
933
962
|
const status = isPlayer ? m.phase : "spectating";
|
|
934
963
|
const opponents = players.filter((id) => id !== myId);
|
|
@@ -939,6 +968,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
939
968
|
role: isPlayer ? "player" : "spectator",
|
|
940
969
|
queue: { position: 0, size: 0 },
|
|
941
970
|
players,
|
|
971
|
+
connectedPlayers,
|
|
942
972
|
opponents,
|
|
943
973
|
teams,
|
|
944
974
|
myTeam,
|
|
@@ -968,9 +998,13 @@ async function matchmake(opts) {
|
|
|
968
998
|
let queueInfo = { position: 0, size: 0 };
|
|
969
999
|
let cancelled = false;
|
|
970
1000
|
let searching = false;
|
|
1001
|
+
let lifecycleEpoch = 0;
|
|
1002
|
+
let seating = false;
|
|
1003
|
+
let requeueing = false;
|
|
971
1004
|
const search = async () => {
|
|
972
1005
|
if (cancelled || searching) return;
|
|
973
1006
|
searching = true;
|
|
1007
|
+
const epoch = ++lifecycleEpoch;
|
|
974
1008
|
try {
|
|
975
1009
|
session = null;
|
|
976
1010
|
matchRoom = null;
|
|
@@ -994,7 +1028,7 @@ async function matchmake(opts) {
|
|
|
994
1028
|
if (cancelled) return;
|
|
995
1029
|
continue;
|
|
996
1030
|
}
|
|
997
|
-
if (cancelled) {
|
|
1031
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
998
1032
|
void q.leave();
|
|
999
1033
|
return;
|
|
1000
1034
|
}
|
|
@@ -1003,10 +1037,13 @@ async function matchmake(opts) {
|
|
|
1003
1037
|
queueInfo = { position: msg.position, size: msg.size };
|
|
1004
1038
|
emit("queue", msg);
|
|
1005
1039
|
});
|
|
1006
|
-
q.onMessage(
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1040
|
+
q.onMessage("seat", (msg) => {
|
|
1041
|
+
if (cancelled || epoch !== lifecycleEpoch || queueRoom !== q || seating) return;
|
|
1042
|
+
seating = true;
|
|
1043
|
+
queueRoom = null;
|
|
1044
|
+
void q.leave();
|
|
1045
|
+
void consumeSeat(msg.reservation, msg.role, epoch);
|
|
1046
|
+
});
|
|
1010
1047
|
q.onLeave(() => {
|
|
1011
1048
|
if (cancelled || queueRoom !== q) return;
|
|
1012
1049
|
queueRoom = null;
|
|
@@ -1026,10 +1063,10 @@ async function matchmake(opts) {
|
|
|
1026
1063
|
searching = false;
|
|
1027
1064
|
}
|
|
1028
1065
|
};
|
|
1029
|
-
const consumeSeat = async (reservation, role) => {
|
|
1066
|
+
const consumeSeat = async (reservation, role, epoch) => {
|
|
1030
1067
|
try {
|
|
1031
1068
|
const mr = await client.consumeSeatReservation(reservation);
|
|
1032
|
-
if (cancelled) {
|
|
1069
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
1033
1070
|
void mr.leave();
|
|
1034
1071
|
return;
|
|
1035
1072
|
}
|
|
@@ -1038,7 +1075,9 @@ async function matchmake(opts) {
|
|
|
1038
1075
|
session = s;
|
|
1039
1076
|
s.on("match:start", (m) => emit("matchStart", m));
|
|
1040
1077
|
s.on("match:ended", (m) => emit("matchEnded", m));
|
|
1041
|
-
s.on("match:requeue", () =>
|
|
1078
|
+
s.on("match:requeue", () => {
|
|
1079
|
+
if (session === s) void requeue();
|
|
1080
|
+
});
|
|
1042
1081
|
s.on("disconnect", () => {
|
|
1043
1082
|
if (!cancelled && session === s) void requeue();
|
|
1044
1083
|
});
|
|
@@ -1049,23 +1088,50 @@ async function matchmake(opts) {
|
|
|
1049
1088
|
emit("matched");
|
|
1050
1089
|
if (role === "spectator") emit("spectate");
|
|
1051
1090
|
} catch {
|
|
1052
|
-
|
|
1091
|
+
if (!cancelled && epoch === lifecycleEpoch) {
|
|
1092
|
+
seating = false;
|
|
1093
|
+
void search();
|
|
1094
|
+
}
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (epoch === lifecycleEpoch) seating = false;
|
|
1053
1097
|
}
|
|
1054
1098
|
};
|
|
1055
1099
|
const requeue = async () => {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1100
|
+
if (cancelled || requeueing) return;
|
|
1101
|
+
requeueing = true;
|
|
1102
|
+
try {
|
|
1103
|
+
lifecycleEpoch++;
|
|
1104
|
+
seating = false;
|
|
1105
|
+
session?.leave();
|
|
1106
|
+
matchRoom = null;
|
|
1107
|
+
session = null;
|
|
1108
|
+
await search();
|
|
1109
|
+
} finally {
|
|
1110
|
+
requeueing = false;
|
|
1111
|
+
}
|
|
1060
1112
|
};
|
|
1061
1113
|
void search();
|
|
1062
1114
|
const liveRoom = () => session ? roomOf(session) : null;
|
|
1115
|
+
let viewSession = null;
|
|
1116
|
+
let lastHydratedView = null;
|
|
1117
|
+
const stableView = () => {
|
|
1118
|
+
if (session !== viewSession) {
|
|
1119
|
+
viewSession = session;
|
|
1120
|
+
lastHydratedView = null;
|
|
1121
|
+
}
|
|
1122
|
+
const live = liveRoom();
|
|
1123
|
+
const hydrated = live?.state?.shared?.get(MATCH_KEY) != null;
|
|
1124
|
+
if (!hydrated && session && lastHydratedView) return lastHydratedView;
|
|
1125
|
+
const next = buildView(live, queueInfo);
|
|
1126
|
+
if (hydrated) lastHydratedView = next;
|
|
1127
|
+
return next;
|
|
1128
|
+
};
|
|
1063
1129
|
return {
|
|
1064
1130
|
get session() {
|
|
1065
1131
|
return session;
|
|
1066
1132
|
},
|
|
1067
1133
|
get matchmaking() {
|
|
1068
|
-
return
|
|
1134
|
+
return stableView();
|
|
1069
1135
|
},
|
|
1070
1136
|
on,
|
|
1071
1137
|
eliminated() {
|
|
@@ -1083,6 +1149,8 @@ async function matchmake(opts) {
|
|
|
1083
1149
|
},
|
|
1084
1150
|
cancel() {
|
|
1085
1151
|
cancelled = true;
|
|
1152
|
+
lifecycleEpoch++;
|
|
1153
|
+
seating = false;
|
|
1086
1154
|
session?.leave();
|
|
1087
1155
|
if (!session && matchRoom) void matchRoom.leave();
|
|
1088
1156
|
if (queueRoom) void queueRoom.leave();
|
package/dist/schema.d.ts
CHANGED
|
@@ -5,10 +5,12 @@ declare const PlayerState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
|
5
5
|
name: "string";
|
|
6
6
|
data: "string";
|
|
7
7
|
epoch: "number";
|
|
8
|
+
connected: "boolean";
|
|
8
9
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
9
10
|
name: "string";
|
|
10
11
|
data: "string";
|
|
11
12
|
epoch: "number";
|
|
13
|
+
connected: "boolean";
|
|
12
14
|
}>>, typeof _colyseus_schema.Schema>;
|
|
13
15
|
type PlayerState = SchemaType<typeof PlayerState>;
|
|
14
16
|
declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
@@ -17,15 +19,18 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
|
17
19
|
name: "string";
|
|
18
20
|
data: "string";
|
|
19
21
|
epoch: "number";
|
|
22
|
+
connected: "boolean";
|
|
20
23
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
21
24
|
name: "string";
|
|
22
25
|
data: "string";
|
|
23
26
|
epoch: "number";
|
|
27
|
+
connected: "boolean";
|
|
24
28
|
}>>, typeof _colyseus_schema.Schema>;
|
|
25
29
|
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
26
30
|
name: "string";
|
|
27
31
|
data: "string";
|
|
28
32
|
epoch: "number";
|
|
33
|
+
connected: "boolean";
|
|
29
34
|
}>, string>;
|
|
30
35
|
};
|
|
31
36
|
shared: {
|
|
@@ -38,15 +43,18 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
|
38
43
|
name: "string";
|
|
39
44
|
data: "string";
|
|
40
45
|
epoch: "number";
|
|
46
|
+
connected: "boolean";
|
|
41
47
|
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
42
48
|
name: "string";
|
|
43
49
|
data: "string";
|
|
44
50
|
epoch: "number";
|
|
51
|
+
connected: "boolean";
|
|
45
52
|
}>>, typeof _colyseus_schema.Schema>;
|
|
46
53
|
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
47
54
|
name: "string";
|
|
48
55
|
data: "string";
|
|
49
56
|
epoch: "number";
|
|
57
|
+
connected: "boolean";
|
|
50
58
|
}>, string>;
|
|
51
59
|
};
|
|
52
60
|
shared: {
|
package/dist/schema.js
CHANGED
|
@@ -6,7 +6,10 @@ var PlayerState = schema(
|
|
|
6
6
|
data: "string",
|
|
7
7
|
// Relay-owned discontinuity epoch. Ordinary state updates keep it stable;
|
|
8
8
|
// state:snap increments it so new SDKs discard interpolation history.
|
|
9
|
-
epoch: "number"
|
|
9
|
+
epoch: "number",
|
|
10
|
+
// Seat presence is distinct from connectivity: a dropped player keeps the
|
|
11
|
+
// seat during reconnection grace but flips connected=false immediately.
|
|
12
|
+
connected: "boolean"
|
|
10
13
|
},
|
|
11
14
|
"PlayerState"
|
|
12
15
|
);
|