anvilsign in

collin/mahjong

master / src / net / room.ts
1/**
2 * The client side of our own room relay (server/rooms.ts), wearing the same
3 * face as the sliver of the antics SDK the game used — code, me, isHost,
4 * state/setState, send/on, the join/leave/host/error subscriptions — so
5 * session.ts barely noticed the ejection. What the relay does not do for us
6 * anymore we do here: reconnect with backoff, and rejoin as the same id.
7 *
8 * Identity is a UUID kept in sessionStorage: per tab, surviving reloads. The
9 * shared seat map is keyed on it, so a reload or a wifi blip walks back into
10 * its own seat. State writes coalesce per microtask; events go at once, so an
11 * fx sent before a state change still arrives before it.
12 */
13
14export interface PlayerInfo {
15 id: string;
16 name: string;
17}
18
19export interface RoomError {
20 code: string;
21 message: string;
22 hint: string;
23}
24
25export interface JoinOptions {
26 /** Room code to join; omit to create a fresh room. */
27 room?: string;
28 /** ws(s):// endpoint override (tests). Defaults to wss://<same-origin>/ws. */
29 server?: string;
30}
31
32type Unsub = () => void;
33
34type ServerMsg =
35 | {
36 t: 'joined';
37 code: string;
38 self: PlayerInfo;
39 hostId: string;
40 players: PlayerInfo[];
41 state: Record<string, unknown>;
42 /** The server's public https origin (its own rsgrok tunnel), or ''. */
43 link?: string;
44 }
45 | { t: 'join'; player: PlayerInfo }
46 | { t: 'leave'; id: string }
47 | { t: 'host'; id: string }
48 | { t: 'set'; patch: Record<string, unknown> }
49 | { t: 'ev'; type: string; payload: unknown; from: string }
50 | { t: 'err'; code: string; message: string; hint: string };
51
52const BASE_BACKOFF = 500;
53const MAX_BACKOFF = 10_000;
54const JOIN_TIMEOUT = 15_000;
55
56function endpoint(server?: string): string {
57 if (server) return server;
58 const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
59 return `${proto}//${window.location.host}/ws`;
60}
61
62/**
63 * A UUID, without needing a secure context.
64 *
65 * `crypto.randomUUID` exists only on https and localhost, and the room links
66 * this game hands out are `http://192.168.…` — the wifi everybody is on, which
67 * is not a secure context. So a phone that scanned the tile on the table would
68 * have thrown here, before it ever reached the room, and the table would have
69 * sat there with a QR nobody could use. `getRandomValues` is not gated the same
70 * way and is the real source; the last fallback is for a browser with neither,
71 * and this is a key into a seat map rather than anything anybody could gain by
72 * guessing.
73 */
74function uuid(): string {
75 const c = globalThis.crypto as Crypto | undefined;
76 if (typeof c?.randomUUID === 'function') return c.randomUUID();
77 const b = new Uint8Array(16);
78 if (typeof c?.getRandomValues === 'function') c.getRandomValues(b);
79 else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
80 b[6] = (b[6] & 0x0f) | 0x40; // version 4
81 b[8] = (b[8] & 0x3f) | 0x80; // variant 1
82 const hex = [...b].map((n) => n.toString(16).padStart(2, '0')).join('');
83 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
84 16,
85 20,
86 )}-${hex.slice(20)}`;
87}
88
89function myId(): string {
90 try {
91 const key = 'mahjong-net-id';
92 let id = sessionStorage.getItem(key);
93 if (!id) {
94 id = uuid();
95 sessionStorage.setItem(key, id);
96 }
97 return id;
98 } catch {
99 return uuid();
100 }
101}
102
103export class Room {
104 code = '';
105 me: PlayerInfo = { id: myId(), name: '' };
106 state: Record<string, unknown> = {};
107 players: PlayerInfo[] = [];
108 /** Where the outside world reaches this server, if anywhere: a public
109 * https origin the invite links and QRs should wear instead of the page's
110 * own (possibly localhost) address. '' when the page's address is it. */
111 publicBase = '';
112
113 private hostId = '';
114 private url: string;
115 private ws: WebSocket | null = null;
116 private joined = false;
117 private left = false;
118 private attempts = 0;
119 private retryTimer: ReturnType<typeof setTimeout> | null = null;
120 private pendingPatch: Record<string, unknown> | null = null;
121 private flushQueued = false;
122 private resolveJoin: ((r: Room) => void) | null = null;
123 private rejectJoin: ((e: Error) => void) | null = null;
124
125 private stateCbs = new Set<(state: Record<string, unknown>, from: PlayerInfo | null) => void>();
126 private joinCbs = new Set<(p: PlayerInfo) => void>();
127 private leaveCbs = new Set<(p: PlayerInfo) => void>();
128 private hostCbs = new Set<(host: PlayerInfo | null) => void>();
129 private resumeCbs = new Set<() => void>();
130 private errorCbs = new Set<(e: RoomError) => void>();
131 private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>();
132
133 private wantRoom: string | undefined;
134
135 private constructor(wantRoom: string | undefined, server?: string) {
136 this.wantRoom = wantRoom;
137 this.url = endpoint(server);
138 }
139
140 /** Connect and resolve once the relay confirms the join with full state. */
141 static join(opts: JoinOptions = {}): Promise<Room> {
142 const room = new Room(opts.room, opts.server);
143 return new Promise<Room>((resolve, reject) => {
144 room.resolveJoin = resolve;
145 room.rejectJoin = reject;
146 const deadline = setTimeout(() => {
147 room.leave();
148 room.rejectJoin?.(new Error('連不上伺服器 could not reach the room server'));
149 room.rejectJoin = null;
150 }, JOIN_TIMEOUT);
151 room.resolveJoin = (r) => {
152 clearTimeout(deadline);
153 resolve(r);
154 };
155 room.connect();
156 });
157 }
158
159 get isHost(): boolean {
160 return this.hostId !== '' && this.hostId === this.me.id;
161 }
162 get host(): PlayerInfo | null {
163 return this.player(this.hostId);
164 }
165 player(id: string): PlayerInfo | null {
166 return this.players.find((p) => p.id === id) ?? null;
167 }
168
169 /** Write shared state (host only): apply locally at once, flush coalesced. */
170 setState(patch: Record<string, unknown>) {
171 if (!this.isHost) {
172 const e = new Error('only the host writes room state') as Error & RoomError;
173 e.code = 'NOT_HOST';
174 e.hint = 'send an event and let the host do the writing';
175 throw e;
176 }
177 Object.assign(this.state, patch);
178 this.pendingPatch = Object.assign(this.pendingPatch ?? {}, patch);
179 if (!this.flushQueued) {
180 this.flushQueued = true;
181 queueMicrotask(() => this.flush());
182 }
183 }
184
185 /** Send an event to everyone else. Dropped while disconnected — an act lost
186 * to a blip is retried by the person, not a queue. */
187 send(type: string, payload?: unknown) {
188 this.post({ t: 'ev', type, payload });
189 }
190
191 leave() {
192 this.left = true;
193 if (this.retryTimer) clearTimeout(this.retryTimer);
194 this.retryTimer = null;
195 this.ws?.close(1000);
196 this.ws = null;
197 }
198
199 onState(cb: (state: Record<string, unknown>, from: PlayerInfo | null) => void): Unsub {
200 return sub(this.stateCbs, cb);
201 }
202 onJoin(cb: (p: PlayerInfo) => void): Unsub {
203 return sub(this.joinCbs, cb);
204 }
205 onLeave(cb: (p: PlayerInfo) => void): Unsub {
206 return sub(this.leaveCbs, cb);
207 }
208 onHostChange(cb: (host: PlayerInfo | null) => void): Unsub {
209 return sub(this.hostCbs, cb);
210 }
211 /**
212 * The connection came back and the room was rejoined. Whatever we meant to
213 * say while it was down never went, and what the server holds is as old as
214 * the blip — so whoever owns the state should say it again.
215 */
216 onResume(cb: () => void): Unsub {
217 return sub(this.resumeCbs, cb);
218 }
219 onError(cb: (e: RoomError) => void): Unsub {
220 return sub(this.errorCbs, cb);
221 }
222 on(type: string, cb: (payload: unknown, from: string) => void): Unsub {
223 let set = this.evCbs.get(type);
224 if (!set) this.evCbs.set(type, (set = new Set()));
225 return sub(set, cb);
226 }
227
228 // ---- the wire ----------------------------------------------------------
229 private connect() {
230 if (this.left) return;
231 const ws = new WebSocket(this.url);
232 this.ws = ws;
233 ws.onopen = () => {
234 this.post({ t: 'hello', id: this.me.id, room: this.code || this.wantRoom });
235 };
236 ws.onmessage = (e) => {
237 let msg: ServerMsg;
238 try {
239 msg = JSON.parse(String(e.data));
240 } catch {
241 return;
242 }
243 this.dispatch(msg);
244 };
245 ws.onclose = () => {
246 if (this.ws !== ws || this.left) return;
247 this.ws = null;
248 this.emitError({
249 code: 'DISCONNECTED',
250 message: '連線中斷 connection lost',
251 hint: 'reconnecting…',
252 });
253 const delay =
254 Math.min(MAX_BACKOFF, BASE_BACKOFF * 2 ** this.attempts++) * (0.75 + Math.random() * 0.5);
255 this.retryTimer = setTimeout(() => this.connect(), delay);
256 };
257 }
258
259 private post(msg: unknown) {
260 if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
261 }
262
263 private flush() {
264 this.flushQueued = false;
265 const patch = this.pendingPatch;
266 if (!patch || this.ws?.readyState !== WebSocket.OPEN) return;
267 this.pendingPatch = null;
268 this.post({ t: 'set', patch });
269 }
270
271 private dispatch(msg: ServerMsg) {
272 switch (msg.t) {
273 case 'joined':
274 return this.applyJoined(msg);
275 case 'join': {
276 if (!this.players.some((p) => p.id === msg.player.id)) this.players.push(msg.player);
277 for (const cb of this.joinCbs) cb(msg.player);
278 return;
279 }
280 case 'leave': {
281 const p = this.player(msg.id);
282 this.players = this.players.filter((q) => q.id !== msg.id);
283 if (p) for (const cb of this.leaveCbs) cb(p);
284 return;
285 }
286 case 'host': {
287 this.hostId = msg.id;
288 for (const cb of this.hostCbs) cb(this.host);
289 return;
290 }
291 case 'set': {
292 Object.assign(this.state, msg.patch);
293 for (const cb of this.stateCbs) cb(this.state, null);
294 return;
295 }
296 case 'ev': {
297 const set = this.evCbs.get(msg.type);
298 if (set) for (const cb of set) cb(msg.payload, msg.from);
299 return;
300 }
301 case 'err':
302 return this.emitError(msg);
303 }
304 }
305
306 private applyJoined(msg: Extract<ServerMsg, { t: 'joined' }>) {
307 const first = !this.joined;
308 this.joined = true;
309 this.attempts = 0;
310 this.pendingPatch = null; // whatever we meant to say, the room has moved on
311 this.code = msg.code;
312 this.me = msg.self;
313 this.publicBase = msg.link ?? '';
314 const before = this.players;
315 this.players = msg.players;
316 const prevHost = this.hostId;
317 this.hostId = msg.hostId;
318 this.state = msg.state ?? {};
319 if (first) {
320 // The game's listeners are registered after join() resolves; it reads
321 // the initial roster and state directly rather than being told.
322 this.resolveJoin?.(this);
323 this.resolveJoin = null;
324 return;
325 }
326 // A resume: the room went on without us. Say everything that changed, in
327 // the order the game expects it — state first (a host's leave handler
328 // rewrites seats, and should rewrite the current ones).
329 for (const cb of this.stateCbs) cb(this.state, null);
330 for (const p of before)
331 if (!this.players.some((q) => q.id === p.id)) for (const cb of this.leaveCbs) cb(p);
332 for (const p of this.players)
333 if (p.id !== this.me.id && !before.some((q) => q.id === p.id))
334 for (const cb of this.joinCbs) cb(p);
335 if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host);
336 for (const cb of this.resumeCbs) cb();
337 }
338
339 private emitError(e: RoomError) {
340 if (this.errorCbs.size === 0) console.warn(`[room] ${e.code}: ${e.message} — ${e.hint}`);
341 for (const cb of this.errorCbs) cb(e);
342 }
343}
344
345function sub<T>(set: Set<T>, cb: T): Unsub {
346 set.add(cb);
347 return () => {
348 set.delete(cb);
349 };
350}
351
352export const joinRoom = (opts: JoinOptions = {}) => Room.join(opts);