anvilsign in

collin/mahjong

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 (an ngrok tunnel, say), 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
62function myId(): string {
63 try {
64 const key = 'mahjong-net-id';
65 let id = sessionStorage.getItem(key);
66 if (!id) {
67 id = crypto.randomUUID();
68 sessionStorage.setItem(key, id);
69 }
70 return id;
71 } catch {
72 return crypto.randomUUID();
73 }
74}
75
76export class Room {
77 code = '';
78 me: PlayerInfo = { id: myId(), name: '' };
79 state: Record<string, unknown> = {};
80 players: PlayerInfo[] = [];
81 /** Where the outside world reaches this server, if anywhere: a public
82 * https origin the invite links and QRs should wear instead of the page's
83 * own (possibly localhost) address. '' when the page's address is it. */
84 publicBase = '';
85
86 private hostId = '';
87 private url: string;
88 private ws: WebSocket | null = null;
89 private joined = false;
90 private left = false;
91 private attempts = 0;
92 private retryTimer: ReturnType<typeof setTimeout> | null = null;
93 private pendingPatch: Record<string, unknown> | null = null;
94 private flushQueued = false;
95 private resolveJoin: ((r: Room) => void) | null = null;
96 private rejectJoin: ((e: Error) => void) | null = null;
97
98 private stateCbs = new Set<(state: Record<string, unknown>, from: PlayerInfo | null) => void>();
99 private joinCbs = new Set<(p: PlayerInfo) => void>();
100 private leaveCbs = new Set<(p: PlayerInfo) => void>();
101 private hostCbs = new Set<(host: PlayerInfo | null) => void>();
102 private errorCbs = new Set<(e: RoomError) => void>();
103 private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>();
104
105 private wantRoom: string | undefined;
106
107 private constructor(wantRoom: string | undefined, server?: string) {
108 this.wantRoom = wantRoom;
109 this.url = endpoint(server);
110 }
111
112 /** Connect and resolve once the relay confirms the join with full state. */
113 static join(opts: JoinOptions = {}): Promise<Room> {
114 const room = new Room(opts.room, opts.server);
115 return new Promise<Room>((resolve, reject) => {
116 room.resolveJoin = resolve;
117 room.rejectJoin = reject;
118 const deadline = setTimeout(() => {
119 room.leave();
120 room.rejectJoin?.(new Error('連不上伺服器 could not reach the room server'));
121 room.rejectJoin = null;
122 }, JOIN_TIMEOUT);
123 room.resolveJoin = (r) => {
124 clearTimeout(deadline);
125 resolve(r);
126 };
127 room.connect();
128 });
129 }
130
131 get isHost(): boolean {
132 return this.hostId !== '' && this.hostId === this.me.id;
133 }
134 get host(): PlayerInfo | null {
135 return this.player(this.hostId);
136 }
137 player(id: string): PlayerInfo | null {
138 return this.players.find((p) => p.id === id) ?? null;
139 }
140
141 /** Write shared state (host only): apply locally at once, flush coalesced. */
142 setState(patch: Record<string, unknown>) {
143 if (!this.isHost) {
144 const e = new Error('only the host writes room state') as Error & RoomError;
145 e.code = 'NOT_HOST';
146 e.hint = 'send an event and let the host do the writing';
147 throw e;
148 }
149 Object.assign(this.state, patch);
150 this.pendingPatch = Object.assign(this.pendingPatch ?? {}, patch);
151 if (!this.flushQueued) {
152 this.flushQueued = true;
153 queueMicrotask(() => this.flush());
154 }
155 }
156
157 /** Send an event to everyone else. Dropped while disconnected — an act lost
158 * to a blip is retried by the person, not a queue. */
159 send(type: string, payload?: unknown) {
160 this.post({ t: 'ev', type, payload });
161 }
162
163 leave() {
164 this.left = true;
165 if (this.retryTimer) clearTimeout(this.retryTimer);
166 this.retryTimer = null;
167 this.ws?.close(1000);
168 this.ws = null;
169 }
170
171 onState(cb: (state: Record<string, unknown>, from: PlayerInfo | null) => void): Unsub {
172 return sub(this.stateCbs, cb);
173 }
174 onJoin(cb: (p: PlayerInfo) => void): Unsub {
175 return sub(this.joinCbs, cb);
176 }
177 onLeave(cb: (p: PlayerInfo) => void): Unsub {
178 return sub(this.leaveCbs, cb);
179 }
180 onHostChange(cb: (host: PlayerInfo | null) => void): Unsub {
181 return sub(this.hostCbs, cb);
182 }
183 onError(cb: (e: RoomError) => void): Unsub {
184 return sub(this.errorCbs, cb);
185 }
186 on(type: string, cb: (payload: unknown, from: string) => void): Unsub {
187 let set = this.evCbs.get(type);
188 if (!set) this.evCbs.set(type, (set = new Set()));
189 return sub(set, cb);
190 }
191
192 // ---- the wire ----------------------------------------------------------
193 private connect() {
194 if (this.left) return;
195 const ws = new WebSocket(this.url);
196 this.ws = ws;
197 ws.onopen = () => {
198 this.post({ t: 'hello', id: this.me.id, room: this.code || this.wantRoom });
199 };
200 ws.onmessage = (e) => {
201 let msg: ServerMsg;
202 try {
203 msg = JSON.parse(String(e.data));
204 } catch {
205 return;
206 }
207 this.dispatch(msg);
208 };
209 ws.onclose = () => {
210 if (this.ws !== ws || this.left) return;
211 this.ws = null;
212 this.emitError({
213 code: 'DISCONNECTED',
214 message: '連線中斷 connection lost',
215 hint: 'reconnecting…',
216 });
217 const delay =
218 Math.min(MAX_BACKOFF, BASE_BACKOFF * 2 ** this.attempts++) * (0.75 + Math.random() * 0.5);
219 this.retryTimer = setTimeout(() => this.connect(), delay);
220 };
221 }
222
223 private post(msg: unknown) {
224 if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
225 }
226
227 private flush() {
228 this.flushQueued = false;
229 const patch = this.pendingPatch;
230 if (!patch || this.ws?.readyState !== WebSocket.OPEN) return;
231 this.pendingPatch = null;
232 this.post({ t: 'set', patch });
233 }
234
235 private dispatch(msg: ServerMsg) {
236 switch (msg.t) {
237 case 'joined':
238 return this.applyJoined(msg);
239 case 'join': {
240 if (!this.players.some((p) => p.id === msg.player.id)) this.players.push(msg.player);
241 for (const cb of this.joinCbs) cb(msg.player);
242 return;
243 }
244 case 'leave': {
245 const p = this.player(msg.id);
246 this.players = this.players.filter((q) => q.id !== msg.id);
247 if (p) for (const cb of this.leaveCbs) cb(p);
248 return;
249 }
250 case 'host': {
251 this.hostId = msg.id;
252 for (const cb of this.hostCbs) cb(this.host);
253 return;
254 }
255 case 'set': {
256 Object.assign(this.state, msg.patch);
257 for (const cb of this.stateCbs) cb(this.state, null);
258 return;
259 }
260 case 'ev': {
261 const set = this.evCbs.get(msg.type);
262 if (set) for (const cb of set) cb(msg.payload, msg.from);
263 return;
264 }
265 case 'err':
266 return this.emitError(msg);
267 }
268 }
269
270 private applyJoined(msg: Extract<ServerMsg, { t: 'joined' }>) {
271 const first = !this.joined;
272 this.joined = true;
273 this.attempts = 0;
274 this.pendingPatch = null; // whatever we meant to say, the room has moved on
275 this.code = msg.code;
276 this.me = msg.self;
277 this.publicBase = msg.link ?? '';
278 const before = this.players;
279 this.players = msg.players;
280 const prevHost = this.hostId;
281 this.hostId = msg.hostId;
282 this.state = msg.state ?? {};
283 if (first) {
284 // The game's listeners are registered after join() resolves; it reads
285 // the initial roster and state directly rather than being told.
286 this.resolveJoin?.(this);
287 this.resolveJoin = null;
288 return;
289 }
290 // A resume: the room went on without us. Say everything that changed, in
291 // the order the game expects it — state first (a host's leave handler
292 // rewrites seats, and should rewrite the current ones).
293 for (const cb of this.stateCbs) cb(this.state, null);
294 for (const p of before)
295 if (!this.players.some((q) => q.id === p.id)) for (const cb of this.leaveCbs) cb(p);
296 for (const p of this.players)
297 if (p.id !== this.me.id && !before.some((q) => q.id === p.id))
298 for (const cb of this.joinCbs) cb(p);
299 if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host);
300 }
301
302 private emitError(e: RoomError) {
303 if (this.errorCbs.size === 0) console.warn(`[room] ${e.code}: ${e.message} — ${e.hint}`);
304 for (const cb of this.errorCbs) cb(e);
305 }
306}
307
308function sub<T>(set: Set<T>, cb: T): Unsub {
309 set.add(cb);
310 return () => {
311 set.delete(cb);
312 };
313}
314
315export const joinRoom = (opts: JoinOptions = {}) => Room.join(opts);