anvilsign in

collin/mahjong

1import { AutoPlay } from '../game/autoplay';
2import type { NetThrow } from '../game/ctl';
3import type { SoundEvent } from '../game/engine';
4import type { Tile } from '../game/tiles';
5import type { Rules, SeatId } from '../game/types';
6import {
7 EMPTY_SEATS,
8 EV_ACT,
9 EV_FX,
10 EV_SEAT,
11 type ActMsg,
12 type FxMsg,
13 type NetMode,
14 type SeatMsg,
15 roomFromUrl,
16 type SeatSlot,
17} from './protocol';
18import { joinRoom, type Room } from './room';
19import { NetTable } from './table';
20
21const SEATS: SeatId[] = [0, 1, 2, 3];
22
23/**
24 * One device's connection to a room, and everything that follows from it.
25 *
26 * The relay elects a host — the earliest joiner still connected — and the host
27 * runs the table: the real engine, the computer players, the answering of
28 * everyone else's intents. Everybody else mirrors the state the host
29 * publishes. If the host leaves, the relay picks a new one, whose mirror is
30 * already current — it starts its own engine from what it was just watching
31 * and the game carries on.
32 *
33 * `role` is this device's, not the room's: the laptop that starts a party game
34 * is the `screen` everyone throws at; every other device is a `player`.
35 */
36export type NetRole = 'player' | 'screen';
37
38export class NetSession {
39 readonly room: Room;
40 readonly table: NetTable;
41 role: NetRole = 'player';
42 /** Latest connection trouble worth showing, or null. */
43 err: string | null = null;
44 /**
45 * A phone threw a tile: the full-size tables put it into their pool so it
46 * flies in from that player's edge. Wired by whoever owns a pool.
47 */
48 onThrowFx: (seat: SeatId, t: NetThrow) => void = () => {};
49
50 private autoplay: AutoPlay | null = null;
51 private stopAutoplay: (() => void) | null = null;
52 private busy: () => boolean = () => false;
53 private listeners = new Set<() => void>();
54 private version = 0;
55 private unsubs: (() => void)[] = [];
56
57 private constructor(room: Room) {
58 this.room = room;
59 this.table = new NetTable({
60 isAuthority: () => this.isHost,
61 mySeat: () => this.mySeat,
62 sendAct: (seat, act) => this.room.send(EV_ACT, { seat, act } as never),
63 });
64
65 const u = this.unsubs;
66 u.push(
67 room.onState(() => {
68 // State flowing means the connection is back, whatever it said before.
69 this.err = null;
70 this.table.adopt(this.sharedGame());
71 this.syncAutoplay();
72 this.bump();
73 }),
74 room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)),
75 room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)),
76 room.on(EV_FX, (p) => this.handleFx(p as unknown as FxMsg)),
77 room.onJoin(() => this.bump()),
78 room.onLeave((p) => {
79 if (this.isHost) this.vacate(p.id);
80 this.bump();
81 }),
82 room.onHostChange(() => {
83 // A new host's engine is its mirror — already current. It only has to
84 // start acting like the host, which `syncAutoplay` and the guards on
85 // every handler do by asking `isHost` fresh each time.
86 this.syncAutoplay();
87 this.bump();
88 }),
89 room.onError((e) => {
90 this.err = `${e.message} — ${e.hint}`;
91 this.bump();
92 }),
93 // The host's own engine is the source of truth: publish every change.
94 this.table.engine.subscribe(() => {
95 if (this.isHost && this.mode) this.publishGame();
96 this.syncAutoplay();
97 }),
98 this.table.engine.onSound((cue) => {
99 if (!this.isHost) return;
100 this.table.emitSound(cue);
101 this.room.send(EV_FX, { fx: 'sfx', kind: cue.kind, tile: cue.tile } as never);
102 }),
103 );
104 // Mirror whatever the room already holds — a late joiner walks in on the
105 // game as it stands, not on an empty table.
106 this.table.adopt(this.sharedGame());
107
108 if (import.meta.env.DEV) {
109 (window as unknown as Record<string, unknown>).__mahjongSession = this;
110 }
111 // One line to identify a session from any console, the sandboxed iframe's
112 // included — where nothing else of ours can be reached from outside.
113 console.log(`[mahjong] joined ${room.code} as ${room.me.name}, host: ${room.isHost}`);
114 room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`));
115 }
116
117 /** Join the room in the page URL, or create a fresh one. */
118 static async join(): Promise<NetSession> {
119 const room = await joinRoom({ room: roomFromUrl() ?? undefined });
120 // The room code goes into the address bar: the page's own URL becomes the
121 // invite link, and a reload walks back into the same room — and, with the
122 // id this device keeps, the same seat.
123 try {
124 const u = new URL(window.location.href);
125 u.searchParams.set('room', room.code);
126 window.history.replaceState(null, '', u);
127 } catch {
128 // A sandbox that refuses history rewrites still gets to play.
129 }
130 return new NetSession(room);
131 }
132
133 leave() {
134 for (const u of this.unsubs) u();
135 this.unsubs = [];
136 this.stopAutoplay?.();
137 this.stopAutoplay = null;
138 this.room.leave();
139 }
140
141 // ---- store plumbing ----------------------------------------------------
142 subscribe = (fn: () => void) => {
143 this.listeners.add(fn);
144 return () => {
145 this.listeners.delete(fn);
146 };
147 };
148 getSnapshot = () => this.version;
149 private bump() {
150 this.version++;
151 for (const l of this.listeners) l();
152 }
153
154 // ---- what the room says ------------------------------------------------
155 get isHost() {
156 return this.room.isHost;
157 }
158 get mode(): NetMode | null {
159 const m = this.room.state.mode;
160 return m === 'online' || m === 'party' ? m : null;
161 }
162 get seats(): SeatSlot[] {
163 const s = this.room.state.seats;
164 return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS;
165 }
166 /** What a seat is called when nobody from the room holds it — the name the
167 * party table dealt it with, or nothing at all online. */
168 private homeName(seat: SeatId): string {
169 const h = this.room.state.homes;
170 return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : '';
171 }
172 private sharedGame(): unknown {
173 return this.room.state.game ?? null;
174 }
175 get mySeat(): SeatId | null {
176 const seats = this.seats;
177 for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i;
178 return null;
179 }
180 /** Whether a hand has actually been dealt in this room. */
181 get started(): boolean {
182 const g = this.table.engine.state;
183 return this.sharedGame() !== null && g.phase !== 'lobby';
184 }
185 /** Everyone here, for the lobby's presence line. */
186 get playerCount() {
187 return this.room.players.length;
188 }
189
190 // ---- lobby / starting --------------------------------------------------
191 /**
192 * Open an online game in this room: publish the mode and take a seat. Only
193 * the host can (room state is the host's to write); the button that calls
194 * this is only shown to the host.
195 */
196 openOnlineLobby() {
197 if (!this.isHost) return;
198 this.room.setState({
199 mode: 'online',
200 seats: structuredClone(EMPTY_SEATS) as never,
201 homes: ['', '', '', ''] as never,
202 });
203 this.claimSeat(0);
204 }
205
206 /** Deal the online game. Seats nobody claimed go to the computer. */
207 startOnline(rules: Rules) {
208 if (!this.isHost) return;
209 const seats = this.seats;
210 const bots = seats.map((s) => s.ids.length === 0);
211 if (bots.every(Boolean)) return; // nobody seated at all
212 const names = seats.map((s, i) => s.name || `玩家 ${i + 1}`);
213 const eng = this.table.engine;
214 eng.applySettings(names, rules);
215 eng.newGame(bots, names);
216 }
217
218 /**
219 * The party table: this device becomes the common screen and deals at once.
220 * All four seats start as people at this screen, exactly like hotseat — a
221 * phone that scans a seat's QR takes that seat over, and gives it back if it
222 * leaves.
223 */
224 startParty(names: string[], rules: Rules) {
225 console.log(`[mahjong] startParty (host: ${this.isHost})`);
226 if (!this.isHost) return;
227 this.role = 'screen';
228 const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' }));
229 this.room.setState({
230 mode: 'party',
231 seats: seats as never,
232 homes: seats.map((s) => s.name) as never,
233 });
234 const eng = this.table.engine;
235 eng.applySettings(names, rules);
236 eng.newGame([false, false, false, false], names);
237 }
238
239 /** Ask for a seat — a numbered one, or any free one. */
240 claimSeat(seat: SeatId | null) {
241 if (this.isHost) this.handleSeat({ seat }, this.room.me.id);
242 else this.room.send(EV_SEAT, { seat } as never);
243 }
244
245 /** Hand a deserted seat to the computer (host's own button). */
246 handToBot(seat: SeatId) {
247 if (!this.isHost) return;
248 if (this.seats[seat].ids.length > 0) return;
249 this.table.engine.setBot(seat, true);
250 }
251
252 /** The pool animations to wait on before a computer player moves. */
253 setBusy(busy: () => boolean) {
254 this.busy = busy;
255 }
256
257 // ---- host duties -------------------------------------------------------
258 /**
259 * A claim never fails. A seat is a hand, not a chair: two phones may hold
260 * the same one — a pair playing together — and whichever acts first acts.
261 * Claiming a different seat lets go of the one you had.
262 */
263 private handleSeat(msg: SeatMsg, from: string) {
264 if (!this.isHost) return;
265 const name = this.room.player(from)?.name ?? '';
266 const seats = structuredClone(this.seats);
267 let seat = msg.seat;
268 if (seat === null) {
269 seat = SEATS.find((i) => seats[i].ids.length === 0) ?? null;
270 if (seat === null) return;
271 }
272 for (const i of SEATS) seats[i].ids = seats[i].ids.filter((id) => id !== from);
273 seats[seat].ids.push(from);
274 // The first holder names the hand; a second pair of hands joins theirs.
275 if (seats[seat].ids.length === 1 && name) seats[seat].name = name;
276 this.settleSeats(seats);
277
278 // Mid-game, the seat comes back from the computer (or the common screen)
279 // to the person who claimed it.
280 if (this.sharedGame() !== null) {
281 this.table.engine.setBot(seat, false);
282 if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name);
283 }
284 this.bump();
285 }
286
287 private vacate(id: string) {
288 const seats = structuredClone(this.seats);
289 let changed = false;
290 for (const i of SEATS) {
291 const kept = seats[i].ids.filter((x) => x !== id);
292 if (kept.length !== seats[i].ids.length) changed = true;
293 seats[i].ids = kept;
294 }
295 if (changed) this.settleSeats(seats);
296 // The seat stays human: at a party table the common screen simply plays it
297 // again, and online the host is offered the 電腦代打 button instead of the
298 // table deciding on its own that a dropped friend is gone for good.
299 }
300
301 /** Publish the seat map, giving emptied hands their own names back. */
302 private settleSeats(seats: SeatSlot[]) {
303 const party = this.mode === 'party';
304 for (const i of SEATS) {
305 if (seats[i].ids.length > 0) continue;
306 if (party) {
307 // Back to the common screen under the table's own name; online the
308 // engine keeps the leaver's name — a deserted hand mid-game is still
309 // theirs to talk about, and to come back to.
310 seats[i].name = this.homeName(i);
311 if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name);
312 } else {
313 seats[i].name = '';
314 }
315 }
316 this.room.setState({ seats: seats as never });
317 }
318
319 private handleAct(msg: ActMsg, from: string) {
320 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
321 const seat = msg.seat;
322 // A hand they hold — or one nobody holds, which is how the party screen
323 // plays its unclaimed seats when it is not the one running the engine
324 // (after a reload, say, when a phone has inherited the host's chair).
325 const holders = this.seats[seat]?.ids ?? [];
326 if (holders.length > 0 && !holders.includes(from)) return;
327 const eng = this.table.engine;
328 const act = msg.act;
329 switch (act.kind) {
330 case 'discard':
331 if (act.thrown) {
332 // Show the throw before the state lands: the pool holds the release
333 // until the discard it belongs to arrives.
334 this.onThrowFx(seat, act.thrown);
335 this.room.send(EV_FX, { fx: 'throw', seat, t: act.thrown } as never);
336 }
337 eng.discard(seat, act.tile as Tile);
338 break;
339 case 'respond':
340 eng.respond(seat, act.claim);
341 break;
342 case 'resolveNow':
343 if (eng.nextDrawer() === seat) eng.resolveNow();
344 break;
345 case 'ankong':
346 eng.declareConcealedKong(seat, act.tile as Tile);
347 break;
348 case 'addkong':
349 eng.declareAddedKong(seat, act.tile as Tile);
350 break;
351 case 'selfDraw':
352 eng.declareSelfDraw(seat);
353 break;
354 case 'nextHand':
355 eng.nextHand();
356 break;
357 case 'newGame':
358 eng.newGame(eng.state.bots);
359 break;
360 }
361 }
362
363 private handleFx(msg: FxMsg) {
364 if (this.isHost || !msg) return;
365 if (msg.fx === 'sfx') {
366 this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile });
367 } else if (msg.fx === 'throw') {
368 this.onThrowFx(msg.seat, msg.t);
369 }
370 }
371
372 private publishGame() {
373 // The engine mutates its state in place; the room keeps what it is handed,
374 // so it gets a copy that will hold still.
375 this.room.setState({ game: structuredClone(this.table.engine.state) as never });
376 }
377
378 /** The computer plays exactly when this device is the host of a live game. */
379 private syncAutoplay() {
380 const should = this.isHost && this.mode !== null && this.sharedGame() !== null;
381 if (should && !this.autoplay) {
382 this.autoplay = new AutoPlay(this.table.engine, () => this.busy());
383 this.stopAutoplay = this.autoplay.start();
384 } else if (!should && this.autoplay) {
385 this.stopAutoplay?.();
386 this.autoplay = null;
387 this.stopAutoplay = null;
388 }
389 }
390}