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