anvilsign in

collin/mahjong

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