@genex-ai/multiplayer 0.9.0 → 0.10.1
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 +14 -13
- package/dist/index.d.ts +22 -16
- package/dist/index.js +83 -23
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +4 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,23 +13,24 @@ 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
|
-
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
|
|
|
@@ -46,7 +47,7 @@ room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-res
|
|
|
46
47
|
|
|
47
48
|
## `players` — everyone in the room
|
|
48
49
|
|
|
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).
|
|
50
|
+
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
51
|
|
|
51
52
|
```ts
|
|
52
53
|
for (const [id, p] of room.players) {
|
|
@@ -246,14 +247,14 @@ bounded extrapolation.
|
|
|
246
247
|
|
|
247
248
|
```ts
|
|
248
249
|
import { connect } from '@genex-ai/multiplayer';
|
|
249
|
-
import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
|
|
250
|
+
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
|
|
250
251
|
type S = { x: number; z: number };
|
|
251
252
|
|
|
252
253
|
await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
|
|
253
254
|
const room = await connect<S>({
|
|
254
|
-
|
|
255
|
+
urls: getColyseusUrls(),
|
|
255
256
|
room: GAME_ID,
|
|
256
|
-
auth: getColyseusAuth()
|
|
257
|
+
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
|
|
257
258
|
});
|
|
258
259
|
|
|
259
260
|
const me = { x: 0, z: 0 };
|
|
@@ -289,7 +290,7 @@ import { matchmake } from "@genex-ai/multiplayer";
|
|
|
289
290
|
|
|
290
291
|
// Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
|
|
291
292
|
// (~10 min); a function is read fresh each join, a static object goes stale mid-session.
|
|
292
|
-
const mm = await matchmake<State>({
|
|
293
|
+
const mm = await matchmake<State>({ urls, room: slug, auth: () => getColyseusAuth() });
|
|
293
294
|
|
|
294
295
|
// `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
|
|
295
296
|
// each frame:
|
|
@@ -317,7 +318,7 @@ A failed queue join is **retried automatically** with backoff (~10s total), so t
|
|
|
317
318
|
(a token mid-rotation, identity still settling on page load) never surface. Only a persistent
|
|
318
319
|
failure emits `error`; the handle then idles until you call `mm.retry()`.
|
|
319
320
|
|
|
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
|
|
321
|
+
**`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
322
|
|
|
322
323
|
Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
|
|
323
324
|
removing it clears the stored config on the next preview/publish — never sent from the client):
|
|
@@ -344,11 +345,11 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
|
|
|
344
345
|
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
|
|
345
346
|
|
|
346
347
|
// host
|
|
347
|
-
const lobby = await createPrivate<State>({
|
|
348
|
+
const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
|
|
348
349
|
share(lobby.code); // e.g. show it in the UI
|
|
349
350
|
|
|
350
351
|
// friend (elsewhere)
|
|
351
|
-
const joined = await joinPrivate<State>(code, {
|
|
352
|
+
const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
|
|
352
353
|
|
|
353
354
|
// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
|
|
354
355
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
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
|
-
/**
|
|
6
|
-
|
|
9
|
+
/** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
|
|
10
|
+
* lowest-latency one; a single entry just connects. */
|
|
11
|
+
urls: string[];
|
|
7
12
|
/** Game id — everyone who passes the same id shares a room; different ids are
|
|
8
13
|
* fully isolated. Use your project slug (or any stable per-game string). */
|
|
9
14
|
room: string;
|
|
@@ -16,18 +21,19 @@ type ConnectOptions = {
|
|
|
16
21
|
* `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForPlayer()`
|
|
17
22
|
* (from `@genex-ai/embed-sdk`). NOT `waitForAuth()` — that resolves only for
|
|
18
23
|
* signed-in accounts and would keep guests out of multiplayer forever.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
21
27
|
*/
|
|
22
|
-
auth?:
|
|
23
|
-
embedToken: string;
|
|
24
|
-
};
|
|
28
|
+
auth?: AuthSource;
|
|
25
29
|
/** Extra join options passed to the room. */
|
|
26
30
|
options?: Record<string, unknown>;
|
|
27
31
|
};
|
|
28
32
|
type PlayerView<TState> = {
|
|
29
33
|
id: string;
|
|
30
34
|
name: string;
|
|
35
|
+
/** Transport is live now. False while this reserved seat is inside reconnect grace. */
|
|
36
|
+
connected: boolean;
|
|
31
37
|
/** State to render — for remote players this is **auto-smoothed** (interpolated);
|
|
32
38
|
* for yourself it's live. Numeric fields are lerped; everything else snaps. */
|
|
33
39
|
state: TState;
|
|
@@ -80,6 +86,8 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
80
86
|
* `state` is auto-smoothed; read `stateRaw` for the raw latest value.
|
|
81
87
|
*/
|
|
82
88
|
readonly players: Map<string, PlayerView<TState>>;
|
|
89
|
+
/** Connected subset of `players`; excludes seats currently held only by reconnect grace. */
|
|
90
|
+
readonly activePlayers: Map<string, PlayerView<TState>>;
|
|
83
91
|
readonly me: {
|
|
84
92
|
/**
|
|
85
93
|
* Publish your synced state (replaces it wholesale). Call on a **fixed tick**
|
|
@@ -197,7 +205,7 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
197
205
|
* await waitForPlayer(); // identity gate (guest OR signed-in)
|
|
198
206
|
* const room = await connect<{ x: number; y: number; z: number }>({
|
|
199
207
|
* url: 'ws://localhost:2567', room: gameId, name: 'ada',
|
|
200
|
-
* auth: getColyseusAuth()
|
|
208
|
+
* auth: () => getColyseusAuth(), // REQUIRED — fresh on every explicit connect call
|
|
201
209
|
* });
|
|
202
210
|
* room.me.set({ x, y, z }); // typed + validated by the compiler
|
|
203
211
|
* room.players.get(id)?.state.x; // number — auto-smoothed for remote players
|
|
@@ -219,6 +227,8 @@ type MatchmakingView = {
|
|
|
219
227
|
};
|
|
220
228
|
/** Every active participant id (N-player: 1v1, FFA, teams). */
|
|
221
229
|
players: string[];
|
|
230
|
+
/** Participants whose transport is live now; graced seats remain only in `players`. */
|
|
231
|
+
connectedPlayers: string[];
|
|
222
232
|
/** The other active player id(s) — everyone in `players` except you. */
|
|
223
233
|
opponents: string[];
|
|
224
234
|
/** teamId -> member ids for team presets; empty for FFA + 1v1. */
|
|
@@ -264,8 +274,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
|
|
|
264
274
|
cancel(): void;
|
|
265
275
|
};
|
|
266
276
|
type MatchmakeOptions = {
|
|
267
|
-
/**
|
|
268
|
-
|
|
277
|
+
/** Regional relay urls (pass `getColyseusUrls()`) — pings each, connects to the lowest-latency one. */
|
|
278
|
+
urls: string[];
|
|
269
279
|
/** Game id (your project slug/id) — isolates this game's matchmaking. */
|
|
270
280
|
room: string;
|
|
271
281
|
name?: string;
|
|
@@ -275,11 +285,7 @@ type MatchmakeOptions = {
|
|
|
275
285
|
* requeue). Pass a FUNCTION — `() => genexEmbed.getColyseusAuth()` — so each (re)join reads a FRESH
|
|
276
286
|
* token; a static object is read only once and will be rejected once it expires.
|
|
277
287
|
*/
|
|
278
|
-
auth?:
|
|
279
|
-
embedToken: string;
|
|
280
|
-
} | (() => {
|
|
281
|
-
embedToken: string;
|
|
282
|
-
} | undefined);
|
|
288
|
+
auth?: AuthSource;
|
|
283
289
|
};
|
|
284
290
|
/**
|
|
285
291
|
* Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
|
|
@@ -310,4 +316,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
|
|
|
310
316
|
*/
|
|
311
317
|
declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
|
|
312
318
|
|
|
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 };
|
|
319
|
+
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
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
@@ -27,6 +27,13 @@ var OBJECT_CONTROL_VERSION = 2;
|
|
|
27
27
|
var STATE_SNAP_MSG = "__state:snap";
|
|
28
28
|
var OBJECT_SNAP_MSG = "__object:snap";
|
|
29
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
|
+
}
|
|
36
|
+
var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
|
|
30
37
|
function parseData(s) {
|
|
31
38
|
if (!s) return { state: {} };
|
|
32
39
|
try {
|
|
@@ -151,11 +158,11 @@ function quantile(xs, q) {
|
|
|
151
158
|
return s[i];
|
|
152
159
|
}
|
|
153
160
|
async function connect(opts) {
|
|
154
|
-
const client =
|
|
161
|
+
const client = await relayClient(opts);
|
|
155
162
|
const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
|
|
156
163
|
gameId: opts.room,
|
|
157
164
|
name: opts.name,
|
|
158
|
-
embedToken: opts.auth?.embedToken,
|
|
165
|
+
embedToken: resolveAuth(opts.auth)?.embedToken,
|
|
159
166
|
...opts.options,
|
|
160
167
|
__v: MP_PROTOCOL
|
|
161
168
|
// tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
|
|
@@ -362,6 +369,7 @@ function attachSession(client, initialRoom) {
|
|
|
362
369
|
};
|
|
363
370
|
const parseCache = /* @__PURE__ */ new Map();
|
|
364
371
|
const playerEpochs = /* @__PURE__ */ new Map();
|
|
372
|
+
let lastPlayersView = /* @__PURE__ */ new Map();
|
|
365
373
|
const cachedParse = (id, raw) => {
|
|
366
374
|
if (!raw) return {};
|
|
367
375
|
const hit = parseCache.get(id);
|
|
@@ -652,7 +660,6 @@ function attachSession(client, initialRoom) {
|
|
|
652
660
|
try {
|
|
653
661
|
const next = await client.reconnect(token);
|
|
654
662
|
room = next;
|
|
655
|
-
connected = true;
|
|
656
663
|
const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
|
|
657
664
|
reconnectStartedAt = 0;
|
|
658
665
|
if (reconnectGap > 1e3) {
|
|
@@ -661,6 +668,7 @@ function attachSession(client, initialRoom) {
|
|
|
661
668
|
const hydrated = wireRoom(next);
|
|
662
669
|
await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
|
|
663
670
|
if (room !== next || dead || intentionalLeave) return;
|
|
671
|
+
connected = true;
|
|
664
672
|
syncHostTicks();
|
|
665
673
|
for (const pending of pendingControls.values()) sendPendingControl(pending);
|
|
666
674
|
emit("reconnected");
|
|
@@ -712,10 +720,22 @@ function attachSession(client, initialRoom) {
|
|
|
712
720
|
} else {
|
|
713
721
|
state = sampleSmoothed(id, localNow) ?? latest;
|
|
714
722
|
}
|
|
715
|
-
out.set(id, {
|
|
723
|
+
out.set(id, {
|
|
724
|
+
id,
|
|
725
|
+
name: p.name,
|
|
726
|
+
connected: p.connected !== false,
|
|
727
|
+
// old relay schemas did not carry the field
|
|
728
|
+
state,
|
|
729
|
+
stateRaw: raw
|
|
730
|
+
});
|
|
716
731
|
});
|
|
732
|
+
if (!connected && out.size === 0 && lastPlayersView.size > 0) return new Map(lastPlayersView);
|
|
733
|
+
lastPlayersView = out;
|
|
717
734
|
return out;
|
|
718
735
|
},
|
|
736
|
+
get activePlayers() {
|
|
737
|
+
return new Map([...session.players].filter(([, player]) => player.connected));
|
|
738
|
+
},
|
|
719
739
|
me: {
|
|
720
740
|
set(state) {
|
|
721
741
|
if (SEND_TIME_KEY in state) {
|
|
@@ -888,7 +908,6 @@ function attachSession(client, initialRoom) {
|
|
|
888
908
|
return session;
|
|
889
909
|
}
|
|
890
910
|
var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
891
|
-
var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
|
|
892
911
|
var matchJoinOptions = (opts) => ({
|
|
893
912
|
gameId: opts.room,
|
|
894
913
|
name: opts.name,
|
|
@@ -905,6 +924,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
905
924
|
role: "player",
|
|
906
925
|
queue: queueInfo,
|
|
907
926
|
players: [],
|
|
927
|
+
connectedPlayers: [],
|
|
908
928
|
opponents: [],
|
|
909
929
|
teams: {},
|
|
910
930
|
myTeam: null,
|
|
@@ -923,6 +943,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
923
943
|
const challengerId = m.challengerId ?? null;
|
|
924
944
|
const teams = m.teams ?? {};
|
|
925
945
|
const players = m.players ?? [championId, challengerId].filter((x) => !!x);
|
|
946
|
+
const connectedPlayers = m.connectedPlayers ?? players;
|
|
926
947
|
const isPlayer = myId != null && players.includes(myId);
|
|
927
948
|
const status = isPlayer ? m.phase : "spectating";
|
|
928
949
|
const opponents = players.filter((id) => id !== myId);
|
|
@@ -933,6 +954,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
933
954
|
role: isPlayer ? "player" : "spectator",
|
|
934
955
|
queue: { position: 0, size: 0 },
|
|
935
956
|
players,
|
|
957
|
+
connectedPlayers,
|
|
936
958
|
opponents,
|
|
937
959
|
teams,
|
|
938
960
|
myTeam,
|
|
@@ -947,7 +969,7 @@ function buildView(matchRoom, queueInfo) {
|
|
|
947
969
|
};
|
|
948
970
|
}
|
|
949
971
|
async function matchmake(opts) {
|
|
950
|
-
const client =
|
|
972
|
+
const client = await relayClient(opts);
|
|
951
973
|
const emitters = /* @__PURE__ */ new Map();
|
|
952
974
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
953
975
|
const on = ((event, cb) => {
|
|
@@ -962,9 +984,13 @@ async function matchmake(opts) {
|
|
|
962
984
|
let queueInfo = { position: 0, size: 0 };
|
|
963
985
|
let cancelled = false;
|
|
964
986
|
let searching = false;
|
|
987
|
+
let lifecycleEpoch = 0;
|
|
988
|
+
let seating = false;
|
|
989
|
+
let requeueing = false;
|
|
965
990
|
const search = async () => {
|
|
966
991
|
if (cancelled || searching) return;
|
|
967
992
|
searching = true;
|
|
993
|
+
const epoch = ++lifecycleEpoch;
|
|
968
994
|
try {
|
|
969
995
|
session = null;
|
|
970
996
|
matchRoom = null;
|
|
@@ -988,7 +1014,7 @@ async function matchmake(opts) {
|
|
|
988
1014
|
if (cancelled) return;
|
|
989
1015
|
continue;
|
|
990
1016
|
}
|
|
991
|
-
if (cancelled) {
|
|
1017
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
992
1018
|
void q.leave();
|
|
993
1019
|
return;
|
|
994
1020
|
}
|
|
@@ -997,10 +1023,13 @@ async function matchmake(opts) {
|
|
|
997
1023
|
queueInfo = { position: msg.position, size: msg.size };
|
|
998
1024
|
emit("queue", msg);
|
|
999
1025
|
});
|
|
1000
|
-
q.onMessage(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1026
|
+
q.onMessage("seat", (msg) => {
|
|
1027
|
+
if (cancelled || epoch !== lifecycleEpoch || queueRoom !== q || seating) return;
|
|
1028
|
+
seating = true;
|
|
1029
|
+
queueRoom = null;
|
|
1030
|
+
void q.leave();
|
|
1031
|
+
void consumeSeat(msg.reservation, msg.role, epoch);
|
|
1032
|
+
});
|
|
1004
1033
|
q.onLeave(() => {
|
|
1005
1034
|
if (cancelled || queueRoom !== q) return;
|
|
1006
1035
|
queueRoom = null;
|
|
@@ -1020,10 +1049,10 @@ async function matchmake(opts) {
|
|
|
1020
1049
|
searching = false;
|
|
1021
1050
|
}
|
|
1022
1051
|
};
|
|
1023
|
-
const consumeSeat = async (reservation, role) => {
|
|
1052
|
+
const consumeSeat = async (reservation, role, epoch) => {
|
|
1024
1053
|
try {
|
|
1025
1054
|
const mr = await client.consumeSeatReservation(reservation);
|
|
1026
|
-
if (cancelled) {
|
|
1055
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
1027
1056
|
void mr.leave();
|
|
1028
1057
|
return;
|
|
1029
1058
|
}
|
|
@@ -1032,7 +1061,9 @@ async function matchmake(opts) {
|
|
|
1032
1061
|
session = s;
|
|
1033
1062
|
s.on("match:start", (m) => emit("matchStart", m));
|
|
1034
1063
|
s.on("match:ended", (m) => emit("matchEnded", m));
|
|
1035
|
-
s.on("match:requeue", () =>
|
|
1064
|
+
s.on("match:requeue", () => {
|
|
1065
|
+
if (session === s) void requeue();
|
|
1066
|
+
});
|
|
1036
1067
|
s.on("disconnect", () => {
|
|
1037
1068
|
if (!cancelled && session === s) void requeue();
|
|
1038
1069
|
});
|
|
@@ -1043,23 +1074,50 @@ async function matchmake(opts) {
|
|
|
1043
1074
|
emit("matched");
|
|
1044
1075
|
if (role === "spectator") emit("spectate");
|
|
1045
1076
|
} catch {
|
|
1046
|
-
|
|
1077
|
+
if (!cancelled && epoch === lifecycleEpoch) {
|
|
1078
|
+
seating = false;
|
|
1079
|
+
void search();
|
|
1080
|
+
}
|
|
1081
|
+
} finally {
|
|
1082
|
+
if (epoch === lifecycleEpoch) seating = false;
|
|
1047
1083
|
}
|
|
1048
1084
|
};
|
|
1049
1085
|
const requeue = async () => {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1086
|
+
if (cancelled || requeueing) return;
|
|
1087
|
+
requeueing = true;
|
|
1088
|
+
try {
|
|
1089
|
+
lifecycleEpoch++;
|
|
1090
|
+
seating = false;
|
|
1091
|
+
session?.leave();
|
|
1092
|
+
matchRoom = null;
|
|
1093
|
+
session = null;
|
|
1094
|
+
await search();
|
|
1095
|
+
} finally {
|
|
1096
|
+
requeueing = false;
|
|
1097
|
+
}
|
|
1054
1098
|
};
|
|
1055
1099
|
void search();
|
|
1056
1100
|
const liveRoom = () => session ? roomOf(session) : null;
|
|
1101
|
+
let viewSession = null;
|
|
1102
|
+
let lastHydratedView = null;
|
|
1103
|
+
const stableView = () => {
|
|
1104
|
+
if (session !== viewSession) {
|
|
1105
|
+
viewSession = session;
|
|
1106
|
+
lastHydratedView = null;
|
|
1107
|
+
}
|
|
1108
|
+
const live = liveRoom();
|
|
1109
|
+
const hydrated = live?.state?.shared?.get(MATCH_KEY) != null;
|
|
1110
|
+
if (!hydrated && session && lastHydratedView) return lastHydratedView;
|
|
1111
|
+
const next = buildView(live, queueInfo);
|
|
1112
|
+
if (hydrated) lastHydratedView = next;
|
|
1113
|
+
return next;
|
|
1114
|
+
};
|
|
1057
1115
|
return {
|
|
1058
1116
|
get session() {
|
|
1059
1117
|
return session;
|
|
1060
1118
|
},
|
|
1061
1119
|
get matchmaking() {
|
|
1062
|
-
return
|
|
1120
|
+
return stableView();
|
|
1063
1121
|
},
|
|
1064
1122
|
on,
|
|
1065
1123
|
eliminated() {
|
|
@@ -1077,6 +1135,8 @@ async function matchmake(opts) {
|
|
|
1077
1135
|
},
|
|
1078
1136
|
cancel() {
|
|
1079
1137
|
cancelled = true;
|
|
1138
|
+
lifecycleEpoch++;
|
|
1139
|
+
seating = false;
|
|
1080
1140
|
session?.leave();
|
|
1081
1141
|
if (!session && matchRoom) void matchRoom.leave();
|
|
1082
1142
|
if (queueRoom) void queueRoom.leave();
|
|
@@ -1128,14 +1188,14 @@ function wirePrivate(client, room) {
|
|
|
1128
1188
|
};
|
|
1129
1189
|
}
|
|
1130
1190
|
async function createPrivate(opts) {
|
|
1131
|
-
const client =
|
|
1191
|
+
const client = await relayClient(opts);
|
|
1132
1192
|
const room = await client.create(MATCH_ROOM_TYPE, matchJoinOptions(opts));
|
|
1133
1193
|
const handle = wirePrivate(client, room);
|
|
1134
1194
|
Object.defineProperty(handle, "code", { value: room.roomId, enumerable: true });
|
|
1135
1195
|
return handle;
|
|
1136
1196
|
}
|
|
1137
1197
|
async function joinPrivate(code, opts) {
|
|
1138
|
-
const client =
|
|
1198
|
+
const client = await relayClient(opts);
|
|
1139
1199
|
const room = await client.joinById(code, matchJoinOptions(opts));
|
|
1140
1200
|
return wirePrivate(client, room);
|
|
1141
1201
|
}
|
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
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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"
|