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