anvilsign in

collin/mahjong

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