| 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 | |
| 14 | export interface PlayerInfo { |
| 15 | id: string; |
| 16 | name: string; |
| 17 | } |
| 18 | |
| 19 | export interface RoomError { |
| 20 | code: string; |
| 21 | message: string; |
| 22 | hint: string; |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 32 | type Unsub = () => void; |
| 33 | |
| 34 | type 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 | |
| 52 | const BASE_BACKOFF = 500; |
| 53 | const MAX_BACKOFF = 10_000; |
| 54 | const JOIN_TIMEOUT = 15_000; |
| 55 | |
| 56 | function 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 | function 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 | |
| 76 | export 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 | |
| 308 | function sub<T>(set: Set<T>, cb: T): Unsub { |
| 309 | set.add(cb); |
| 310 | return () => { |
| 311 | set.delete(cb); |
| 312 | }; |
| 313 | } |
| 314 | |
| 315 | export const joinRoom = (opts: JoinOptions = {}) => Room.join(opts); |