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 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 * The party table: this device becomes the common screen and deals at once.
243 * All four seats start as people at this screen, exactly like hotseat — a
244 * phone that scans a seat's QR takes that seat over, and gives it back if it
245 * leaves.
246 */
247 startParty(names: string[], rules: Rules) {
248 console.log(`[mahjong] startParty (host: ${this.isHost})`);
249 if (!this.isHost) return;
250 this.role = 'screen';
251 const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' }));
252 this.room.setState({
253 mode: 'party',
254 seats: seats as never,
255 homes: seats.map((s) => s.name) as never,
256 voices: [false, false, false, false] as never,
257 });
258 const eng = this.table.engine;
259 eng.applySettings(names, rules);
260 eng.newGame([false, false, false, false], names);
261 }
262
263 /** Ask for a seat — a numbered one, or any free one. */
264 claimSeat(seat: SeatId | null) {
265 if (this.isHost) this.handleSeat({ seat }, this.room.me.id);
266 else this.room.send(EV_SEAT, { seat } as never);
267 }
268
269 /** Hand a deserted seat to the computer (host's own button). */
270 handToBot(seat: SeatId) {
271 if (!this.isHost) return;
272 if (this.seats[seat].ids.length > 0) return;
273 this.table.engine.setBot(seat, true);
274 }
275
276 /** The pool animations to wait on before a computer player moves. */
277 setBusy(busy: () => boolean) {
278 this.busy = busy;
279 }
280
281 // ---- host duties -------------------------------------------------------
282 /**
283 * A claim never fails. A seat is a hand, not a chair: two phones may hold
284 * the same one — a pair playing together — and whichever acts first acts.
285 * Claiming a different seat lets go of the one you had.
286 */
287 private handleSeat(msg: SeatMsg, from: string) {
288 if (!this.isHost) return;
289 const name = this.room.player(from)?.name ?? '';
290 const seats = structuredClone(this.seats);
291 let seat = msg.seat;
292 if (seat === null) {
293 seat = SEATS.find((i) => seats[i].ids.length === 0) ?? null;
294 if (seat === null) return;
295 }
296 for (const i of SEATS) seats[i].ids = seats[i].ids.filter((id) => id !== from);
297 seats[seat].ids.push(from);
298 // The first holder names the hand; a second pair of hands joins theirs.
299 if (seats[seat].ids.length === 1 && name) seats[seat].name = name;
300 this.settleSeats(seats);
301
302 // Mid-game, the seat comes back from the computer (or the common screen)
303 // to the person who claimed it.
304 if (this.sharedGame() !== null) {
305 this.table.engine.setBot(seat, false);
306 if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name);
307 }
308 this.bump();
309 }
310
311 private vacate(id: string) {
312 const seats = structuredClone(this.seats);
313 let changed = false;
314 for (const i of SEATS) {
315 const kept = seats[i].ids.filter((x) => x !== id);
316 if (kept.length !== seats[i].ids.length) changed = true;
317 seats[i].ids = kept;
318 }
319 if (changed) this.settleSeats(seats);
320 // The seat stays human: at a party table the common screen simply plays it
321 // again, and online the host is offered the 電腦代打 button instead of the
322 // table deciding on its own that a dropped friend is gone for good.
323 }
324
325 /** Publish the seat map, giving emptied hands their own names back. */
326 private settleSeats(seats: SeatSlot[]) {
327 const party = this.mode === 'party';
328 for (const i of SEATS) {
329 if (seats[i].ids.length > 0) continue;
330 if (party) {
331 // Back to the common screen under the table's own name; online the
332 // engine keeps the leaver's name — a deserted hand mid-game is still
333 // theirs to talk about, and to come back to.
334 seats[i].name = this.homeName(i);
335 if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name);
336 } else {
337 seats[i].name = '';
338 }
339 }
340 this.room.setState({ seats: seats as never });
341 // A hand that changed hands changed who speaks for it.
342 this.publishVoices();
343 }
344
345 private handleAct(msg: ActMsg, from: string) {
346 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
347 const seat = msg.seat;
348 // A hand they hold — or one nobody holds, which is how the party screen
349 // plays its unclaimed seats when it is not the one running the engine
350 // (after a reload, say, when a phone has inherited the host's chair).
351 const holders = this.seats[seat]?.ids ?? [];
352 if (holders.length > 0 && !holders.includes(from)) return;
353 const eng = this.table.engine;
354 const act = msg.act;
355 switch (act.kind) {
356 case 'discard':
357 if (act.thrown) {
358 // Show the throw before the state lands: the pool holds the release
359 // until the discard it belongs to arrives.
360 this.onThrowFx(seat, act.thrown);
361 this.room.send(EV_FX, { fx: 'throw', seat, t: act.thrown } as never);
362 }
363 eng.discard(seat, act.tile as Tile);
364 break;
365 case 'respond':
366 eng.respond(seat, act.claim);
367 break;
368 case 'resolveNow':
369 if (eng.nextDrawer() === seat) eng.resolveNow();
370 break;
371 case 'ankong':
372 eng.declareConcealedKong(seat, act.tile as Tile);
373 break;
374 case 'addkong':
375 eng.declareAddedKong(seat, act.tile as Tile);
376 break;
377 case 'selfDraw':
378 eng.declareSelfDraw(seat);
379 break;
380 case 'nextHand':
381 eng.nextHand();
382 break;
383 case 'newGame':
384 eng.newGame(eng.state.bots);
385 break;
386 }
387 }
388
389 private handleFx(msg: FxMsg) {
390 if (this.isHost || !msg) return;
391 if (msg.fx === 'sfx') {
392 this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile, seat: msg.seat });
393 } else if (msg.fx === 'throw') {
394 this.onThrowFx(msg.seat, msg.t);
395 }
396 }
397
398 // ---- who is going to say it -------------------------------------------
399 /**
400 * Tell the host whether this device would actually say a call out loud if
401 * one arrived. Called from wherever the answer can change — the mute button,
402 * the settings panel, the first tap that wakes a phone's audio. Repeats are
403 * dropped: the wire only carries changes.
404 */
405 reportVoice(ready: boolean) {
406 this.myVoice = ready;
407 if (ready === this.toldVoice) return;
408 this.toldVoice = ready;
409 if (this.isHost) this.handleVoice({ ready }, this.room.me.id);
410 else this.room.send(EV_VOICE, { ready } as never);
411 }
412
413 private handleVoice(msg: VoiceMsg, from: string) {
414 if (!this.isHost || !msg) return;
415 const ready = msg.ready === true;
416 if (this.talkers.get(from) === ready) return;
417 this.talkers.set(from, ready);
418 this.publishVoices();
419 }
420
421 /**
422 * Work out, seat by seat, whether somebody's own device is going to speak
423 * for it, and publish that for every screen in the room.
424 *
425 * Only a seat *held* by a phone counts: the seats the common screen is still
426 * playing hotseat-style have no voice of their own, and the table goes on
427 * saying those. The host's own device is excluded even when it holds a seat
428 * on the party screen, because it is the table — if it stopped saying a call
429 * on the grounds that it was going to say it, nobody would say it at all.
430 */
431 private publishVoices() {
432 if (!this.isHost) return;
433 const screen = this.mode === 'party' ? this.room.me.id : '';
434 const voices = SEATS.map((i) =>
435 this.seats[i].ids.some((id) => id !== screen && this.talkers.get(id) === true),
436 );
437 const was = this.room.state.voices;
438 if (Array.isArray(was) && was.length === 4 && voices.every((v, i) => was[i] === v)) return;
439 this.room.setState({ voices: voices as never });
440 this.bump();
441 }
442
443 /**
444 * Which seats are speaking for themselves, as the host last worked it out.
445 * A table that knows this stays quiet for those seats and lets the calls come
446 * from the phones instead — see `App.tsx`.
447 */
448 get voiceSeats(): boolean[] {
449 const v = this.room.state.voices;
450 return Array.isArray(v) && v.length === 4 ? (v as boolean[]) : [false, false, false, false];
451 }
452
453 private publishGame() {
454 // The engine mutates its state in place; the room keeps what it is handed,
455 // so it gets a copy that will hold still.
456 this.room.setState({ game: structuredClone(this.table.engine.state) as never });
457 }
458
459 /** The computer plays exactly when this device is the host of a live game. */
460 private syncAutoplay() {
461 const should = this.isHost && this.mode !== null && this.sharedGame() !== null;
462 if (should && !this.autoplay) {
463 this.autoplay = new AutoPlay(this.table.engine, () => this.busy());
464 this.stopAutoplay = this.autoplay.start();
465 } else if (!should && this.autoplay) {
466 this.stopAutoplay?.();
467 this.autoplay = null;
468 this.stopAutoplay = null;
469 }
470 }
471}