anvilsign in

collin/mahjong

1import type { NetThrow } from '../game/ctl';
2import type { Game, SoundEvent } from '../game/engine';
3import type { Tile } from '../game/tiles';
4import type { SeatId } from '../game/types';
5import {
6 EMPTY_SEATS,
7 EV_ACT,
8 EV_FX,
9 EV_SEAT,
10 EV_VOICE,
11 type ActMsg,
12 type FxMsg,
13 type SeatMsg,
14 roomFromUrl,
15 type SeatSlot,
16 type VoiceMsg,
17} from './protocol';
18import { joinRoom, type Room } from './room';
19import { NetTable } from './table';
20
21const SEATS: SeatId[] = [0, 1, 2, 3];
22
23/**
24 * How long a hand waits for the device that was holding it before the computer
25 * takes it. A wifi blip, a phone locking itself, a walk to the kitchen — all
26 * shorter than this; a battery going flat is not. The seat is not lost either
27 * way: coming back takes it straight off the computer again, and so does one
28 * tap on it at the table.
29 */
30const EMPTY_SEAT_GRACE = 15_000;
31
32/**
33 * One device's connection to a room, and everything that follows from it.
34 *
35 * The relay elects a host — the earliest joiner still connected — and the host
36 * runs the table: the real engine, the computer players, the answering of
37 * everyone else's intents. Everybody else mirrors the state the host
38 * publishes. If the host leaves, the relay picks a new one, whose mirror is
39 * already current — it starts its own engine from what it was just watching
40 * and the game carries on.
41 *
42 * There is one kind of game in here. The room carries who holds which hand and
43 * which device the table is being played at, and every arrangement people call
44 * a mode — four round a laptop, four in four cities, one against three
45 * computers — is some setting of those two. A room is only opened when a phone
46 * is wanted; until then none of this runs at all.
47 */
48export class NetSession {
49 readonly room: Room;
50 readonly table: NetTable;
51 /** Latest connection trouble worth showing, or null. */
52 err: string | null = null;
53 /**
54 * A phone threw a tile: the full-size tables put it into their pool so it
55 * flies in from that player's edge. Wired by whoever owns a pool.
56 */
57 onThrowFx: (seat: SeatId, t: NetThrow) => void = () => {};
58
59 /** Hands whose device has gone, counting down to the computer taking them. */
60 private waiting = new Map<SeatId, ReturnType<typeof setTimeout>>();
61 /** Who in the room has said they can talk. Kept by the host; see `voices`. */
62 private talkers = new Map<string, boolean>();
63 /** The last thing we told the host about ourselves, so we only say changes. */
64 private toldVoice: boolean | null = null;
65 /** Whether *we* can talk, kept so a new host can be told the same thing. */
66 private myVoice = false;
67 private listeners = new Set<() => void>();
68 private version = 0;
69 private unsubs: (() => void)[] = [];
70
71 private constructor(room: Room, engine: Game) {
72 this.room = room;
73 this.table = new NetTable(
74 {
75 isAuthority: () => this.isHost,
76 mySeat: () => this.mySeat,
77 sendAct: (seat, act) => this.room.send(EV_ACT, { seat, act } as never),
78 },
79 engine,
80 );
81
82 const u = this.unsubs;
83 u.push(
84 room.onState(() => {
85 // State flowing means the connection is back, whatever it said before.
86 this.err = null;
87 this.table.adopt(this.sharedGame());
88 this.reviewSeats();
89 this.bump();
90 }),
91 room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)),
92 room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)),
93 room.on(EV_FX, (p) => this.handleFx(p as unknown as FxMsg)),
94 room.on(EV_VOICE, (p, from) => this.handleVoice(p as unknown as VoiceMsg, from)),
95 room.onJoin(() => this.bump()),
96 room.onLeave((p) => {
97 if (this.isHost) {
98 this.talkers.delete(p.id);
99 // The table itself walking out — a closed laptop, a phone that
100 // started the game and went home. Nobody inherits being the table:
101 // the hands it was playing have nobody behind them now, and say so.
102 if (p.id === this.screenId) this.room.setState({ screen: '' as never });
103 this.vacate(p.id);
104 this.armStranded();
105 this.publishVoices();
106 }
107 this.bump();
108 }),
109 room.onHostChange(() => {
110 // A new host's engine is its mirror — already current. It only has to
111 // start acting like the host, which the guards on every handler do by
112 // asking `isHost` fresh each time.
113 //
114 // The countdowns on empty hands are not inherited — they lived in the
115 // old host's timers — so they are worked out again from what the room
116 // says, which is where the answer was all along.
117 this.reviewSeats();
118 this.armStranded();
119 // What it does not inherit is who told the old host they could talk,
120 // so everybody says it again.
121 this.toldVoice = null;
122 this.reportVoice(this.myVoice);
123 this.bump();
124 }),
125 // Back after a blip. The room's copy of the table is as old as the gap
126 // and whatever we tried to publish across it was dropped, so the device
127 // that owns the table says it all again.
128 room.onResume(() => {
129 if (this.isHost && this.sharedGame() !== null) this.publishGame();
130 this.reviewSeats();
131 this.armStranded();
132 }),
133 room.onError((e) => {
134 this.err = `${e.message} — ${e.hint}`;
135 this.bump();
136 }),
137 // The host's own engine is the source of truth: publish every change.
138 this.table.engine.subscribe(() => {
139 if (this.isHost && this.sharedGame() !== null) this.publishGame();
140 this.reviewSeats();
141 }),
142 this.table.engine.onSound((cue) => {
143 if (!this.isHost) return;
144 this.table.emitSound(cue);
145 this.room.send(EV_FX, {
146 fx: 'sfx',
147 kind: cue.kind,
148 tile: cue.tile,
149 seat: cue.seat,
150 } as never);
151 }),
152 );
153 // Mirror whatever the room already holds — a late joiner walks in on the
154 // game as it stands, not on an empty table.
155 this.table.adopt(this.sharedGame());
156
157 if (import.meta.env.DEV) {
158 (window as unknown as Record<string, unknown>).__mahjongSession = this;
159 }
160 // One line to identify a session from any console, the sandboxed iframe's
161 // included — where nothing else of ours can be reached from outside.
162 console.log(`[mahjong] joined ${room.code} as ${room.me.name}, host: ${room.isHost}`);
163 room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`));
164 }
165
166 /**
167 * Join the room in the page URL, or create a fresh one, and put it around the
168 * game this page is already playing. The engine is the page's own: a room is
169 * a wrapper, not a second table, so opening one mid-hand costs nothing.
170 */
171 static async join(engine: Game): Promise<NetSession> {
172 const room = await joinRoom({ room: roomFromUrl() ?? undefined });
173 // The room code goes into the address bar: the page's own URL becomes the
174 // invite link, and a reload walks back into the same room — and, with the
175 // id this device keeps, the same seat.
176 try {
177 const u = new URL(window.location.href);
178 u.searchParams.set('room', room.code);
179 window.history.replaceState(null, '', u);
180 } catch {
181 // A sandbox that refuses history rewrites still gets to play.
182 }
183 return new NetSession(room, engine);
184 }
185
186 leave() {
187 for (const u of this.unsubs) u();
188 this.unsubs = [];
189 for (const t of this.waiting.values()) clearTimeout(t);
190 this.waiting.clear();
191 this.room.leave();
192 }
193
194 // ---- store plumbing ----------------------------------------------------
195 subscribe = (fn: () => void) => {
196 this.listeners.add(fn);
197 return () => {
198 this.listeners.delete(fn);
199 };
200 };
201 getSnapshot = () => this.version;
202 private bump() {
203 this.version++;
204 for (const l of this.listeners) l();
205 }
206
207 // ---- what the room says ------------------------------------------------
208 get isHost() {
209 return this.room.isHost;
210 }
211 /**
212 * The device the table is being played at — the one that opened the room. It
213 * is where every hand no phone has taken is played, and it is the one screen
214 * that goes quiet for a seat whose phone is doing the talking.
215 */
216 get screenId(): string {
217 const id = this.room.state.screen;
218 return typeof id === 'string' ? id : '';
219 }
220 /** Whether this device is that one. */
221 get isScreen(): boolean {
222 return this.screenId !== '' && this.screenId === this.room.me.id;
223 }
224 get seats(): SeatSlot[] {
225 const s = this.room.state.seats;
226 return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS;
227 }
228 /** What a seat is called when nobody from the room holds it — the name the
229 * table dealt it with. */
230 private homeName(seat: SeatId): string {
231 const h = this.room.state.homes;
232 return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : '';
233 }
234 private sharedGame(): unknown {
235 return this.room.state.game ?? null;
236 }
237 get mySeat(): SeatId | null {
238 const seats = this.seats;
239 for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i;
240 return null;
241 }
242 /** Whether this room has a table in it yet — one has been published, and a
243 * hand actually dealt. */
244 get open(): boolean {
245 return this.sharedGame() !== null && this.table.engine.state.phase !== 'lobby';
246 }
247 /** Everyone here, for the lobby's presence line. */
248 get playerCount() {
249 return this.room.players.length;
250 }
251
252 // ---- opening the room --------------------------------------------------
253 /**
254 * Put this device's table into the room.
255 *
256 * The game is already being played — a room is never opened around an empty
257 * table — so this publishes it as it stands rather than dealing anything.
258 * All four seats start held by nobody, which is what they already were:
259 * hands played at this screen. A phone that scans a seat's QR takes one over
260 * and gives it back if it leaves.
261 *
262 * Only the host can, because room state is the host's to write. Whoever
263 * created the room is the host, so the device that asked for the QR is
264 * normally the one that gets here; a device that joined somebody else's room
265 * has nothing to publish and returns.
266 */
267 publishTable(names: string[]) {
268 if (!this.isHost) return;
269 if (this.sharedGame() !== null) return; // already open — somebody beat us to it
270 console.log('[mahjong] opening the room around this table');
271 const seats = SEATS.map((i) => ({ ids: [] as string[], name: names[i] ?? '' }));
272 this.room.setState({
273 seats: seats as never,
274 homes: seats.map((q) => q.name) as never,
275 voices: [false, false, false, false] as never,
276 screen: this.room.me.id as never,
277 game: structuredClone(this.table.engine.state) as never,
278 });
279 this.bump();
280 }
281
282 /**
283 * Hand a seat to the computer, or take it back off it. Asked for from the
284 * table — the screen's own button, and the tap on a computer's strip that
285 * says somebody has sat down there.
286 */
287 setBot(seat: SeatId, bot: boolean) {
288 if (bot && this.seats[seat].ids.length > 0) return; // somebody is holding it
289 this.clearWait(seat);
290 if (this.isHost) this.table.engine.setBot(seat, bot);
291 else this.room.send(EV_ACT, { seat, act: { kind: 'bot', on: bot } } as never);
292 }
293
294 /** Ask for a seat — a numbered one, or any free one. */
295 claimSeat(seat: SeatId | null) {
296 if (this.isHost) this.handleSeat({ seat }, this.room.me.id);
297 else this.room.send(EV_SEAT, { seat } as never);
298 }
299
300 // ---- host duties -------------------------------------------------------
301 /**
302 * A claim never fails. A seat is a hand, not a chair: two phones may hold
303 * the same one — a pair playing together — and whichever acts first acts.
304 * Claiming a different seat lets go of the one you had.
305 */
306 private handleSeat(msg: SeatMsg, from: string) {
307 if (!this.isHost) return;
308 const name = this.room.player(from)?.name ?? '';
309 const seats = structuredClone(this.seats);
310 let seat = msg.seat;
311 if (seat === null) {
312 seat = SEATS.find((i) => seats[i].ids.length === 0) ?? null;
313 if (seat === null) return;
314 }
315 for (const i of SEATS) seats[i].ids = seats[i].ids.filter((id) => id !== from);
316 seats[seat].ids.push(from);
317 // The first holder names the hand; a second pair of hands joins theirs.
318 if (seats[seat].ids.length === 1 && name) seats[seat].name = name;
319 this.settleSeats(seats);
320
321 // The hand comes back from the computer — or off the screen it was being
322 // played at — to the person who claimed it. Mid-hand and all: taking a
323 // seat is taking whatever is in it, which is the point.
324 this.clearWait(seat);
325 if (this.sharedGame() !== null) {
326 this.table.engine.setBot(seat, false);
327 if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name);
328 }
329 this.bump();
330 }
331
332 /**
333 * Somebody's device is gone. Any hand it was the last one holding starts
334 * counting down, and the computer takes it when the count runs out — the
335 * table has to be able to keep playing, and a hand nobody is holding is a
336 * hand nobody is playing.
337 *
338 * Not immediately, because a phone locking itself looks exactly like a phone
339 * leaving for good and only one of them means it. And not for keeps either
340 * way: whoever it was walks back into the same seat when they come back, and
341 * anyone at the table can take it off the computer with one tap.
342 */
343 private vacate(id: string) {
344 const seats = structuredClone(this.seats);
345 const emptied: SeatId[] = [];
346 for (const i of SEATS) {
347 const kept = seats[i].ids.filter((x) => x !== id);
348 if (kept.length === seats[i].ids.length) continue;
349 seats[i].ids = kept;
350 if (kept.length === 0) emptied.push(i);
351 }
352 if (emptied.length === 0) return;
353 this.settleSeats(seats);
354 for (const i of emptied) this.armWait(i);
355 }
356
357 /**
358 * Hands with nobody on them and no table behind them either — the screen
359 * closed its lid, and the phones that are left are each holding their own.
360 * Same countdown, same computer at the end of it.
361 */
362 private armStranded() {
363 if (!this.isHost || this.sharedGame() === null) return;
364 if (this.screenId !== '' && this.room.player(this.screenId)) return;
365 // The screen went without anyone left to notice — it was the host, so its
366 // own leave handler never ran. Whoever inherited the room says so now.
367 if (this.screenId !== '') this.room.setState({ screen: '' as never });
368 for (const i of SEATS) {
369 if (this.seats[i].ids.length === 0 && !this.table.engine.state.bots[i]) this.armWait(i);
370 }
371 }
372
373 private armWait(seat: SeatId) {
374 if (!this.isHost || this.waiting.has(seat)) return;
375 this.waiting.set(
376 seat,
377 setTimeout(() => {
378 this.waiting.delete(seat);
379 if (!this.isHost || this.seats[seat].ids.length > 0) return;
380 this.table.engine.setBot(seat, true);
381 this.bump();
382 }, EMPTY_SEAT_GRACE),
383 );
384 this.bump();
385 }
386
387 private clearWait(seat: SeatId) {
388 const t = this.waiting.get(seat);
389 if (t === undefined) return;
390 clearTimeout(t);
391 this.waiting.delete(seat);
392 this.bump();
393 }
394
395 /** A hand somebody walked away from, still being kept for them. */
396 isWaiting(seat: SeatId): boolean {
397 return this.waiting.has(seat);
398 }
399
400 /** Countdowns on hands that have since been filled, or given away, are over. */
401 private reviewSeats() {
402 if (!this.isHost) {
403 for (const t of this.waiting.values()) clearTimeout(t);
404 this.waiting.clear();
405 return;
406 }
407 const bots = this.table.engine.state.bots;
408 for (const i of SEATS) {
409 if (this.seats[i].ids.length > 0 || bots[i]) this.clearWait(i);
410 }
411 }
412
413 /**
414 * Publish the seat map, giving emptied hands the table's own name for them
415 * back — 東家, or whatever the settings called it. The person who was
416 * holding it took their name with them when they went.
417 */
418 private settleSeats(seats: SeatSlot[]) {
419 for (const i of SEATS) {
420 if (seats[i].ids.length > 0) continue;
421 seats[i].name = this.homeName(i);
422 if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name);
423 }
424 this.room.setState({ seats: seats as never });
425 // A hand that changed hands changed who speaks for it.
426 this.publishVoices();
427 }
428
429 private handleAct(msg: ActMsg, from: string) {
430 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
431 const seat = msg.seat;
432 // A hand they hold — or, for a hand nobody holds, the device the table is
433 // being played at, which is how the screen plays its unclaimed seats when
434 // it is not the one running the engine (after a reload, say, when a phone
435 // has inherited the host's chair).
436 const holders = this.seats[seat]?.ids ?? [];
437 const table = this.screenId || this.room.me.id;
438 if (holders.length > 0 ? !holders.includes(from) : from !== table) return;
439 const eng = this.table.engine;
440 const act = msg.act;
441 switch (act.kind) {
442 case 'discard':
443 if (act.thrown) {
444 // Show the throw before the state lands: the pool holds the release
445 // until the discard it belongs to arrives.
446 this.onThrowFx(seat, act.thrown);
447 this.room.send(EV_FX, { fx: 'throw', seat, t: act.thrown } as never);
448 }
449 eng.discard(seat, act.tile as Tile);
450 break;
451 case 'respond':
452 eng.respond(seat, act.claim);
453 break;
454 case 'resolveNow':
455 if (eng.nextDrawer() === seat) eng.resolveNow();
456 break;
457 case 'ankong':
458 eng.declareConcealedKong(seat, act.tile as Tile);
459 break;
460 case 'addkong':
461 eng.declareAddedKong(seat, act.tile as Tile);
462 break;
463 case 'selfDraw':
464 eng.declareSelfDraw(seat);
465 break;
466 case 'nextHand':
467 eng.nextHand();
468 break;
469 case 'newGame':
470 eng.newGame(eng.state.bots);
471 break;
472 case 'bot':
473 // Only ever about a hand nobody is holding — the guard above has
474 // already turned away anyone asking about somebody else's.
475 this.clearWait(seat);
476 eng.setBot(seat, act.on);
477 break;
478 }
479 }
480
481 private handleFx(msg: FxMsg) {
482 if (this.isHost || !msg) return;
483 if (msg.fx === 'sfx') {
484 this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile, seat: msg.seat });
485 } else if (msg.fx === 'throw') {
486 this.onThrowFx(msg.seat, msg.t);
487 }
488 }
489
490 // ---- who is going to say it -------------------------------------------
491 /**
492 * Tell the host whether this device would actually say a call out loud if
493 * one arrived. Called from wherever the answer can change — the mute button,
494 * the settings panel, the first tap that wakes a phone's audio. Repeats are
495 * dropped: the wire only carries changes.
496 */
497 reportVoice(ready: boolean) {
498 this.myVoice = ready;
499 if (ready === this.toldVoice) return;
500 this.toldVoice = ready;
501 if (this.isHost) this.handleVoice({ ready }, this.room.me.id);
502 else this.room.send(EV_VOICE, { ready } as never);
503 }
504
505 private handleVoice(msg: VoiceMsg, from: string) {
506 if (!this.isHost || !msg) return;
507 const ready = msg.ready === true;
508 if (this.talkers.get(from) === ready) return;
509 this.talkers.set(from, ready);
510 this.publishVoices();
511 }
512
513 /**
514 * Work out, seat by seat, whether somebody's own device is going to speak
515 * for it, and publish that for every screen in the room.
516 *
517 * Only a seat *held* by a phone counts: the seats the common screen is still
518 * playing hotseat-style have no voice of their own, and the table goes on
519 * saying those. The host's own device is excluded even when it holds a seat
520 * on the party screen, because it is the table — if it stopped saying a call
521 * on the grounds that it was going to say it, nobody would say it at all.
522 */
523 private publishVoices() {
524 if (!this.isHost) return;
525 const screen = this.screenId;
526 const voices = SEATS.map((i) =>
527 this.seats[i].ids.some((id) => id !== screen && this.talkers.get(id) === true),
528 );
529 const was = this.room.state.voices;
530 if (Array.isArray(was) && was.length === 4 && voices.every((v, i) => was[i] === v)) return;
531 this.room.setState({ voices: voices as never });
532 this.bump();
533 }
534
535 /**
536 * Which seats are speaking for themselves, as the host last worked it out.
537 * A table that knows this stays quiet for those seats and lets the calls come
538 * from the phones instead — see `App.tsx`.
539 */
540 get voiceSeats(): boolean[] {
541 const v = this.room.state.voices;
542 return Array.isArray(v) && v.length === 4 ? (v as boolean[]) : [false, false, false, false];
543 }
544
545 private publishGame() {
546 // The engine mutates its state in place; the room keeps what it is handed,
547 // so it gets a copy that will hold still.
548 this.room.setState({ game: structuredClone(this.table.engine.state) as never });
549 }
550}