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