anvilsign in

collin/mahjong · 2317d367

One game, and the hands change hands while it is being played

Collin Richards · 2026-08-25 10:04 UTC · 2317d367e8f3d4971d4fb5809b0bd7e6ec2c9c6d · parent ca82579b · browse files

modifiedREADME.md+118 −79
⋯ 6 unchanged lines
77 English underneath; the tiles themselves stay Chinese, because that is what the
88 tiles say.
99
10-**四人同桌** on the lobby is that table, and it is one mode rather than two:
11-four people at one screen, and any of them free to take their own hand onto
12-their phone from the QR in their gear — the tiles leave the screen and the
13-throw comes back over the wire. Nobody has to, and nothing has to be decided up
14-front. See [one table](#one-table).
15-
16-Nobody else around? **單人對局 Single player** gives you the bottom seat and
17-hands the other three to the computer — same rules, same table, same scoring.
18-See [computer players](#computer-players).
19-
20-Everybody somewhere else? **線上對戰 Online game** puts the four of you at the
21-same table from four devices, each turned so your own seat is the near edge —
22-see [playing online](#playing-online).
10+**開始** is the only button on the lobby, because there is one game. It deals
11+you in at the bottom edge with the computer on the other three, on this device,
12+without a word said to any server. Everything people call a mode is that table
13+with different hands changing hands, one at a time, mid-game:
14+
15+- somebody sits down at this screen and **taps a hand the computer has been
16+ playing** — the tiles turn over and are theirs;
17+- somebody **scans a seat's QR** and takes that hand onto their phone;
18+- somebody **opens the invite link** from three cities away and takes one from
19+ their own laptop, with the table turned so their seat is the near edge.
20+
21+Each of those swaps one seat and redeals nothing, and each undoes: a hand goes
22+back to the computer with a tap, and one that loses its device gets given back
23+to the computer on its own so the table never stops. See
24+[one table](#one-table), [computer players](#computer-players) and
25+[playing online](#playing-online).
2326
2427 ```
2528 npm install
⋯ 29 unchanged lines
5558 strip has is 看牌: every other one names a tile — 打出 五萬, 吃 with these two
5659 — and would say through the card what is under it. The 看牌 of whoever the
5760 table is waiting on wears a gold ring, since whose go it is was never a
58- secret. Nothing is covered where the screen belongs to one player: solo, and
59- online, where everybody has a device of their own.
61+ secret. Nothing is covered where the screen is playing one hand, which is a
62+ screen that belongs to one player — three computers and you, or your own
63+ tiles on your own device.
6064 - **The tile you just drew** is held apart from the sorted hand with a gold ring
6165 and a 摸 label, instead of being sorted invisibly into it. It merges into the
6266 hand once you discard. 補花 replacements and kong replacements are marked the
⋯ 91 unchanged lines
154158 skipped; the line the bar leaves behind breathes, and the table goes on
155159 waiting. It is a nudge, not a rule. Said on a covered hand too — whose turn
156160 it is was never one of the things the card is hiding — and only where the
157- screen is shared: solo has nobody to tell, and online everybody is already
158- looking at their own device. A phone at the table gets the same seven seconds
161+ screen is playing more than one hand, since a screen playing one has nobody
162+ to tell. A phone at the table gets the same seven seconds
159163 across its top, a gold rim edge to edge (red for a claim window), and one
160164 short buzz as the game comes round to it.
161165 - **On the keys** — ← and → walk the selection along your hand and space throws
⋯ 41 unchanged lines
203207 whose 碰 that was without looking up, which is the whole point of hearing it.
204208 Tiles out in the middle are placed properly rather than by edge: the physics
205209 knows to the pixel where a tile landed, and that is where the knock comes
206- from. Online the table is turned so your own seat is at the bottom, and the
207- sound turns with it. See [audio, by seat](#audio-by-seat).
210+ from. A device holding one hand turns the table so that hand is at the
211+ bottom, and the sound turns with it. See [audio, by seat](#audio-by-seat).
208212 - **Settings** — 設定 from the lobby, or from the game-over panel: player names,
209213 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
210214 `localStorage` separately from the save, so they carry over to the next game.
⋯ 2 unchanged lines
213217
214218 ## One table
215219
216-四人同桌 and 手機派對 were two buttons on the lobby, and they were never two
217-games: one was the other with the phones left in people's pockets. Every party
218-table played its unclaimed seats hotseat-style at the screen already, and every
219-hotseat table would have taken a phone if it had had a room to put it in. So
220-there is one button now, and picking up your phone is not a mode — it is
221-something one player does, mid-hand, without asking the other three.
222-
223-What that costs is a decision about when to open the room, and the answer is:
224-not while anybody is waiting. **The tiles are dealt at once**, on this screen,
225-before a websocket has been opened — a laptop with no server behind it is still
226-four people at a table, and nobody should watch a spinner to start playing. The
227-room is opened behind the game, and when it lands it *inherits* the hand in
228-progress (`startParty(names, rules, from)` restores rather than deals) instead
229-of dealing over the top of it. The only thing that changes on screen is that
230-the QRs start working. If it never lands, nothing does.
220+There were three buttons on this lobby — 單人對局, 四人同桌, 線上對戰 — and
221+they were never three games. Every party table played its unclaimed seats
222+hotseat-style at the screen already; every hotseat table would have taken a
223+phone if it had had a room to put it in; every online table filled its empty
224+chairs with the same computer players the solo game is made of. They differed
225+in who was holding which hand, and that is not something a lobby should have to
226+know before the tiles are dealt.
227+
228+So there is one button, and the question it used to ask is answered
229+continuously instead. A hand is played by one of three things — **the computer,
230+somebody at this screen, or a device of its own** — and any of them becomes any
231+other while the hand is in progress, with nothing redealt and nobody consulted
232+but the person doing it.
233+
234+| | played by | becomes a person here | becomes a phone |
235+|---|---|---|---|
236+| 電腦 | the bots in `autoplay.ts` | tap the strip — 坐下 | its QR, from its gear |
237+| 這裡 | this screen, face down under a tap | — | its QR, from its gear |
238+| 手機 | whoever scanned in | they leave → the computer | — |
239+
240+**Nothing touches the network until a phone is wanted.** A table of people and
241+computers is four hands and an engine; there is nothing for a server to do, and
242+`src/net/` is never constructed. The room is opened by the first thing that
243+needs one — the 用手機拿牌 in a seat's gear — and when it opens it *publishes
244+the hand in progress* rather than dealing over the top of it
245+(`NetSession.publishTable`). The engine is the page's own either way:
246+`NetTable` is a wrapper that goes on around a live `Game`, not a second one, so
247+opening a room costs a `structuredClone` and changes nothing on screen except
248+that the QRs start working.
231249
232250 **Each seat's QR is its own**, and lives in that seat's gear beside the sound
233251 and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair
⋯ 4 unchanged lines
238256 still being sat down at; the moment the first tile is thrown it steps aside,
239257 because from then on the middle is where the tiles go.
240258
241-**The save follows the screen.** A table that has opened a room keeps writing
242-`localStorage` (`engine.persist` on the screen's engine, off on every phone —
243-they mirror, and have no business writing it), so 繼續對局 still works and
244-resumes the same way the mode starts: restore here, open the room behind it,
245-publish the hand as it stood.
259+**A hand that loses its device is kept for fifteen seconds and then given to
260+the computer** (`EMPTY_SEAT_GRACE`). A phone locking itself looks exactly like
261+a phone leaving for good and only one of them means it, so the nameplate shows
262+⏳ and the table waits; when the wait is up the computer picks the tiles up and
263+play carries on. Nothing is lost either way — coming back takes the seat
264+straight off the computer again, and so does one tap on it at the table. The
265+same countdown covers the screen closing its lid: the room records which device
266+the table is being played at (`screen` in room state), and hands with neither a
267+device nor a table behind them go the same way.
268+
269+**The save follows the table.** Whichever device is running the game writes
270+`localStorage` (`engine.persist = authority`; every mirror has it off, and no
271+business writing it), so 繼續對局 restores here and the room, if one is wanted
272+again, opens behind it exactly as it did the first time.
273+
274+**Covering is arithmetic, not a mode.** A hand starts face down when this
275+device is playing more than one of them — which is the definition of a screen
276+several people are sitting round. One hand is one person, whether they are
277+playing three computers or holding their own tiles on their own phone, and
278+covering your own tiles from yourself is a tap in the way.
246279
247280 ## Playing online
248281
249-Both networked shapes — a shared screen with phones on it, and four devices in
250-four places — are one room relay of our own: `server/rooms.ts`,
282+Every device that is not the table is on one room relay of our own:
283+`server/rooms.ts`,
251284 a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong:
252285 rooms with codes, host election by join order, one bag of shared state only the
253286 host may write, and events fanned out to everyone else. One client — the
⋯ 4 unchanged lines
258291 end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game
259292 riding it.
260293
261-- **線上對戰 Online game** — everyone on their own device. Whoever opens the
262- room picks 線上對戰 and gets a seat lobby; friends who open the same link
263- land in it, sit down, and the host deals. Empty seats go to the computer,
264- which runs on the host client — that is what stands in for a server on the
265- classic (free) tier. Each screen draws the same full table, turned so your
266- own seat is the bottom edge; other people's tiles are face down. Joining
267- mid-game takes over a vacant or computer seat, so a dropped friend can
268- reload and carry on.
269-- **四人同桌 at one screen, with phones** — the mode above, and on the wire
270- still `mode: 'party'`: the laptop is the table and a phone holds one hand.
271- Scanning a seat's QR loads a controller: just that hand, face up, with the
272- claim buttons — and the throw. Flick a tile up off the top of the phone and
273- it sails in from your edge of the common screen, at the speed and angle you
274- let go of it (`NetThrow`, mapped into table coordinates by
275- `TablePool.throwFromNet`). The seat's tiles go face down on the shared screen
276- and stay there; hold the 📱 tag on its nameplate to show the table's QR again
277- for a player who lost theirs. A phone that leaves hands the seat back to the
278- laptop, where it is covered-with-a-tap like the rest. Seats never lock: a
279- second phone scanning the same QR joins the same hand — two people playing
280- one hand, whoever acts first acts. And with sound on, the phone in your hand
281- is what says your calls — see [audio, by seat](#audio-by-seat).
294+- **A device that holds one hand** shows that hand. A phone shows just the
295+ hand, face up, with the claim buttons — and the throw: flick a tile up off
296+ the top of the phone and it sails in from your edge of the common screen, at
297+ the speed and angle you let go of it (`NetThrow`, mapped into table
298+ coordinates by `TablePool.throwFromNet`). Anything bigger shows the whole
299+ table instead, turned so that seat is the bottom edge, with everyone else's
300+ tiles face down. Same room, same seat map; the only thing deciding between
301+ them is how much screen there is.
302+- **A device that holds no hand yet** gets the seat picker, and every seat is
303+ offered — including the ones the computer is playing, which is the ordinary
304+ way in, and the ones somebody already holds, which is how two people play one
305+ hand off two devices. A seat's own QR names its chair and skips the picker.
306+- **The device the table was opened on** goes on showing the table: the felt,
307+ the middle, the wall, and every hand no phone has taken, covered-with-a-tap
308+ like a hotseat game. Hold the 📱 tag on a nameplate to show the table's QR
309+ again for a player who lost theirs. With sound on, the phone in each player's
310+ hand is what says their calls and the table goes quiet for that seat — see
311+ [audio, by seat](#audio-by-seat).
282312
283313 Design notes, in the order they bit:
284314
⋯ 10 unchanged lines
295325 and throttle timers in background tabs, so nothing in `src/net/` runs on a
296326 loop: state writes flush on a microtask, everything inbound arrives over the
297327 WebSocket (whose delivery is not throttled), and dead connections are the
298- server's ping loop's problem. A party screen somebody tabbed away from
328+ server's ping loop's problem. A table screen somebody tabbed away from
299329 keeps answering.
300330 - **Identity is a UUID in sessionStorage**, chosen by the device and taken at
301331 its word by the relay. The seat map is keyed on it, so a reload or a wifi
⋯ 146 unchanged lines
448478
449479 The game state is plain data, so a save is just its JSON in `localStorage`,
450480 rewritten after every move. Close the lid, refresh, or run the battery flat and
451-nothing is lost — the lobby offers **繼續對局 Resume** above the ways to start a
452-fresh one, showing whether it was a solo game or a table, the round, hand
453-number, chip counts and when it was saved. Which seats the computer holds is
454-part of the state, so a save resumes as the kind of game it was — a solo game
455-here, a table with its room opened again behind it, so the phones that were on
456-it can come back. A
481+nothing is lost — the lobby offers **繼續對局 Resume** above 開始, showing the
482+round, hand number, chip counts and when it was saved. Which seats the computer
483+holds is part of the state, so a save comes back with the same hands in the
484+same places, and everybody who was on a phone scans back in exactly as they did
485+the first time — a room is opened around a resumed table the same way it is
486+opened around a fresh one, when somebody wants one. It is written by whichever
487+device is running the game (`engine.persist = authority`), so the mirrors on
488+everyone else's screens never fight it for the slot. A
457489 save is validated before it is offered (four players, 144 tiles accounted for),
458490 so a truncated or hand-edited one is ignored rather than loaded into a broken
459491 table. `VERSION` in `save.ts` retires old saves if the state shape changes.
⋯ 62 unchanged lines
522554
523555 **A seat becomes a place.** `App.tsx` turns a seat into a *position round this
524556 screen* — 0 the near edge, 1 the right, 2 across, 3 the left — because that is
525-what the ear wants and the seat number is not: online the table is turned so
526-you are always at the bottom, and the sound has to turn with it.
557+what the ear wants and the seat number is not: a device holding one hand turns
558+the table so that hand is at the bottom, and the sound has to turn with it.
527559 `game/sound.ts` gives each position a bus of its own: a pan, a gain, and a
528560 lowpass that opens all the way for the near edge and closes to 5 kHz for the
529561 far one. Distance is two things at once — quieter, and with the top taken off
⋯ 5 unchanged lines
535567 rather than building one per contact. Old Safari has no `StereoPannerNode`; it
536568 plays where it always did.
537569
538-**At a shared table, the words come out of the player.** Five devices in one
539-room, and the split the sound should take is the one the room already has: the
570+**Where there is a table and phones round it, the words come out of the
571+player.** The split the sound should take is the one the room already has: the
540572 table makes the noise, because the tiles are on the table, and the phone in
541573 somebody's hand says their calls, because the calls are theirs. So the table's
542574 screen drops the *voice* for a seat while keeping the clack, and the phone
543575 holding that seat says 碰 and names its own discards and makes no table noise
544576 at all.
545577
578+**Only a phone claims that job**, and that is the whole of how the split knows
579+where it applies. A phone is small enough to be in a hand and a hand is in the
580+room; a friend playing from their own laptop three cities away is not, and
581+never takes the words away from anybody's table. It is a proxy rather than a
582+fact — software cannot know who is sitting where — but it is the one property
583+that actually correlates with it, and it fails in the harmless direction:
584+the worst a wrong guess costs is a call said twice in two rooms.
585+
546586 That only works if the phone really is going to say it, so it is asked rather
547587 than assumed. `sound.voiceLive()` is the honest answer — sound on, 報牌 on, the
548588 audio hardware awake, the pack decoded — and every device reports it to the
⋯ 1 unchanged line
550590 hands have a device that will speak for them and publishes that as `voices` in
551591 room state; a nameplate wearing 🔊 next to its 📱 is a seat the table has gone
552592 quiet for on purpose. Mute the phone, put it to sleep, hand the seat back —
553-the flag drops and the table takes the calls back mid-hand. The host's own
554-device never counts, even when it holds seats: it is the table, and a table
555-that fell silent on the grounds that it was about to speak would say nothing at
556-all.
593+the flag drops and the table takes the calls back mid-hand. The device the
594+table is being played at never counts, even when it holds seats: it *is* the
595+table, and a table that fell silent on the grounds that it was about to speak
596+would say nothing at all.
557597
558598 A browser will not start audio before the page has been touched, and a phone
559599 that arrived by QR may never touch anything that asks for it by name — so the
⋯ 105 unchanged lines
665705 src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay
666706 src/net/protocol.ts what crosses the wire, in one place
667707 src/net/handOrder.ts your own arrangement, kept on your own device
668-src/ui/Controller.tsx the phone at the shared table: your tiles and the throw
669-src/ui/OnlineLobby.tsx seats and the deal button, before an online game
708+src/ui/Controller.tsx a hand held on a phone, plus the seat picker for any device
670709 server/rooms.ts the relay: rooms, host election, state fan-out
671710 server/index.ts production: dist/ and the relay from one port
672711 src/table/physics.ts the discard pool: rectangles with weight, pure and testable
⋯ 232 unchanged lines
modifiedTODO.md+5 −4
⋯ 25 unchanged lines
2626 - [ ] put it on a real domain: `npm run serve` behind a TLS reverse proxy
2727 (phones only get camera and fullscreen on https) — anvil, a VPS, a
2828 tailscale funnel, anything that terminates TLS
29-- [ ] undo online: only the party screen has it, and only while it is the
30- room's host; online games have none at all
31-- [ ] full-table online clients throw with the local gesture but others see
32- the default seat-launch animation — could send the NetThrow for those too
29+- [ ] undo on a device that is not the table: only the table screen has it, and
30+ only while it is the room's host. A phone holding one hand has none
31+- [ ] full-size clients holding one hand throw with the local gesture but
32+ others see the default seat-launch animation — could send the NetThrow
33+ for those too
3334 - [ ] cheat-resistance: room state carries the wall and every hand; the relay
3435 is ours now, so the engine could move into `server/rooms.ts` and keep
3536 secrets server-side
⋯ 10 unchanged lines
modifiedsrc/App.tsx+263 −226
11 import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
22 import { AutoPlay } from './game/autoplay';
33 import type { TableCtl } from './game/ctl';
4-import { Game, type GameState, type SoundCue } from './game/engine';
4+import { Game, type SoundCue } from './game/engine';
55 import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs';
66 import { loadSave } from './game/save';
77 import {
⋯ 10 unchanged lines
1818 } from './game/sound';
1919 import { WIND_EN, WIND_NAMES } from './game/tiles';
2020 import type { SeatId } from './game/types';
21-import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol';
21+import { roomFromUrl, roomLink } from './net/protocol';
2222 import { NetSession } from './net/session';
2323 import { Bi, BiText } from './ui/Bi';
2424 import { Center } from './ui/Center';
2525 import { exposeGame, showingColliders } from './ui/colliders';
2626 import { useCompact } from './ui/compact';
27-import { Controller } from './ui/Controller';
27+import { Controller, SeatPick } from './ui/Controller';
2828 import { FullscreenToggle } from './ui/FullscreenToggle';
2929 import { enterFullscreen, useFullscreen } from './ui/fullscreen';
3030 import { Help } from './ui/Help';
3131 import { pick, setLang, useLang, useT } from './ui/lang';
3232 import { LanguageToggle } from './ui/LanguageToggle';
33-import { OnlineLobby } from './ui/OnlineLobby';
3433 import { TablePool } from './table/pool';
3534 import { Pool } from './ui/Pool';
36-import { Seat, type RemoteSeat } from './ui/Seat';
35+import { Seat, type SeatWho } from './ui/Seat';
3736 import { TableMenu } from './ui/TableMenu';
3837 import { SeatQr } from './ui/SeatQr';
3938 import { Settings } from './ui/Settings';
4039 import './styles.css';
4140
4241 const SEATS: SeatId[] = [0, 1, 2, 3];
43-/** The human always takes the bottom seat; the other three are the computer's. */
44-const SOLO_SEATS = [false, true, true, true];
45-const HOTSEAT_SEATS = [false, false, false, false];
42+/**
43+ * What 開始 deals: you at the bottom edge, the computer on the other three.
44+ *
45+ * It is a starting point rather than a mode. Anybody sitting down at this
46+ * screen taps a computer's strip and takes that hand; anybody with a phone
47+ * scans into it; and either of those can be given back to the computer again,
48+ * mid-hand, without the table stopping. See `seatWho` below.
49+ */
50+const OPENING_SEATS = [false, true, true, true];
4651
47-// One join per page, whoever asks first — the lobby button, or the ?room= a
48-// shared link carries. A promise rather than a flag, so a second caller waits
49-// on the same join instead of starting another.
52+// One join per page, whoever asks first — the invite, or the ?room= a shared
53+// link carries. A promise rather than a flag, so a second caller waits on the
54+// same join instead of starting another.
5055 let joining: Promise<NetSession> | null = null;
51-const joinOnce = () => (joining ??= NetSession.join());
56+const joinOnce = (engine: Game) => (joining ??= NetSession.join(engine));
5257
5358 const subNothing = () => () => {};
5459 const zero = () => 0;
⋯ 6 unchanged lines
6166 interface Earshot {
6267 /** Seat number → which edge of *this* screen it is sitting at. */
6368 posOf: (seat: SeatId) => SeatId;
64- mode: 'online' | 'party' | null;
6569 mySeat: SeatId | null;
66- /** Whether this device is the party table rather than a hand at it. */
67- isScreen: boolean;
70+ /** Whether this device is the one the table is being played at. */
71+ isTable: boolean;
72+ /** Whether this device is a hand held beside a table that is making the
73+ * noise for it — a phone in a room. */
74+ handheld: boolean;
6875 /** Seats whose own phone is saying their calls for them. */
6976 voices: boolean[];
7077 }
⋯ 1 unchanged line
7279 /**
7380 * Which parts of a cue this device plays, and where from.
7481 *
75- * A single screen — solo, hotseat, or an online table — plays all of it, panned
76- * to the edge the seat is sitting at, so 碰 from 上家 comes from your left.
82+ * Anything showing the whole table plays all of it, panned to the edge the
83+ * seat is sitting at, so 碰 from 上家 comes from your left. That covers a
84+ * screen on its own, and a screen with a room around it, and somebody playing
85+ * from their own laptop three cities away.
7786 *
78- * A party table is a room with five devices in it, and the sound is split the
79- * way the room already splits: the table makes the noise, because the tiles
80- * are on the table, and the phone in a player's hand says the words, because
81- * the words are theirs. So the table drops the voice for any seat whose phone
82- * has said it can talk, and a phone plays nothing but its own seat's calls —
83- * plus the chime for a claim window somebody else opened, which is a nudge
84- * meant for the person holding the phone and nobody else in the room.
87+ * A phone in a room is the exception, and the reason is the room: the table is
88+ * right there making the noise, so the phone makes none of it. It says its own
89+ * seat's calls, because the words are that player's, and it chimes for a claim
90+ * window somebody else opened, which is a nudge meant for the person holding
91+ * it and nobody else. The table hears the phone claim the job — see `voices`
92+ * in `net/protocol.ts` — and goes quiet for that seat, so the 碰 comes out of
93+ * the hand of whoever called it.
8594 */
8695 function castFor(cue: SoundCue, room: Earshot): Cast {
8796 const seat = cue.seat;
88- if (room.mode === 'party' && !room.isScreen) {
97+ if (room.handheld) {
8998 // A phone still on the seat picker holds nothing and speaks for nobody.
9099 if (seat === undefined || room.mySeat === null) return { voice: false, noise: false };
91100 const mine = seat === room.mySeat;
⋯ 2 unchanged lines
94103 return { pos: null, voice: mine, noise: mine ? false : cue.kind === 'claimWindow' };
95104 }
96105 const pos = seat === undefined ? null : (room.posOf(seat) as Pos);
97- const spoken = room.mode === 'party' && seat !== undefined && room.voices[seat] === true;
106+ const spoken = room.isTable && seat !== undefined && room.voices[seat] === true;
98107 return { pos, voice: !spoken, noise: true };
99108 }
100109
⋯ 31 unchanged lines
132141 useSyncExternalStore(session?.subscribe ?? subNothing, session?.getSnapshot ?? zero);
133142 useSyncExternalStore(session?.table.subscribe ?? subNothing, session?.table.getSnapshot ?? zero);
134143
135- const connect = async (): Promise<NetSession | null> => {
144+ /**
145+ * Get this page into a room. `open` says this device is the one bringing the
146+ * table: it publishes what it is already playing, and from then on the QRs
147+ * work and a phone can take a hand off it.
148+ *
149+ * The publish happens before the session is handed to React on purpose — a
150+ * render in between would catch a room that has joined but has no table in
151+ * it yet, and flash the seat picker at the very device that owns the table.
152+ */
153+ const connect = async (open = false): Promise<NetSession | null> => {
136154 setConnecting(true);
137155 console.log('[mahjong] connecting…');
138156 try {
139- const s = await joinOnce();
157+ const s = await joinOnce(game);
158+ if (open) s.publishTable(prefs.names);
140159 setSession(s);
141160 setNetErr(null);
142161 return s;
⋯ 18 unchanged lines
161180 // eslint-disable-next-line react-hooks/exhaustive-deps
162181 }, []);
163182
164- // The full-size tables hand phone throws to the pool, and hold the computer
165- // players while something is still skidding across it.
183+ // The full-size tables hand phone throws to the pool, so a tile flicked off
184+ // a phone comes flying in from that player's edge.
166185 useEffect(() => {
167186 if (!session) return;
168187 session.onThrowFx = (seat, t) => {
169188 pool.throwFromNet(seat, t);
170189 pool.wake();
171190 };
172- session.setBusy(() => pool.busy);
173191 return () => {
174192 session.onThrowFx = () => {};
175- session.setBusy(() => false);
176193 };
177194 }, [session, pool]);
178195 // Where every cue is heard from, kept in a ref rather than in the closures
⋯ 1 unchanged line
180197 // table, and re-subscribing on every rotation would drop cues mid-hand.
181198 const earshot = useRef<Earshot>({
182199 posOf: (s) => s,
183- mode: null,
184200 mySeat: null,
185- isScreen: false,
201+ isTable: true,
202+ handheld: false,
186203 voices: [false, false, false, false],
187204 });
188- useEffect(
189- () => session?.table.onSound((cue) => play(cue, castFor(cue, earshot.current))),
190- [session],
191- );
192205
193- // Walking back into a running online game unseated — a reload, or a friend
194- // arriving late — takes the first empty chair, deserted or computer-run.
195- const reclaimed = useRef(false);
196- useEffect(() => {
197- if (!session || session.mode !== 'online' || !session.started) return;
198- if (reclaimed.current || session.mySeat !== null) return;
199- if (session.seats.some((s) => s.ids.length === 0)) {
200- reclaimed.current = true;
201- session.claimSeat(null);
202- }
203- });
204-
205- const netMode = session?.mode ?? null;
206206 const mySeat = session?.mySeat ?? null;
207- // The party table's common screen. Usually the device that started the game;
208- // after a reload that flag is gone, so anything full-sized and unseated in a
209- // party room shows the table rather than a phone's controller — unless it
210- // arrived through a seat's QR, which is a hand asking to be held whatever
211- // the screen size.
212- const isScreen =
213- session?.role === 'screen' ||
214- (netMode === 'party' && !compact && mySeat === null && seatFromUrl() === null);
215- const netActive = !!session && (netMode === 'party' ? true : netMode === 'online' && session.started);
207+ /**
208+ * Whether this device is the one the table is being played at.
209+ *
210+ * Without a room it always is — there is only the one screen. With one it is
211+ * whichever device opened it, which is where every hand no phone has taken
212+ * goes on being played. Everything else — a phone that scanned in, a friend
213+ * on their own laptop — is holding a hand, not running a table.
214+ */
215+ const isTable = !session || session.isScreen;
216+ /** A hand held on a phone, with the table it belongs to somewhere else. */
217+ const handheld = !!session && compact && !isTable;
216218
217- // Which engine this screen draws and drives.
218- const ctl: TableCtl = netActive ? session!.table : game;
219+ /**
220+ * The table this page is playing at. It is the same engine either way: a
221+ * room is a wrapper that goes on around a game in progress, not a second
222+ * game, which is what lets four people join one at a time without the tiles
223+ * being dealt again.
224+ */
225+ const ctl: TableCtl = session ? session.table : game;
219226 const phase = ctl.state.phase;
220- // Online the table is turned so your own seat is at the bottom edge.
221- const viewSeat: SeatId = netMode === 'online' ? mySeat ?? 0 : 0;
227+ /** Whether this device is the one actually running the game. */
228+ const authority = !session || session.isHost;
229+
230+ // ---- who is playing which hand -----------------------------------------
231+ const holdersOf = (seat: SeatId): string[] => session?.seats[seat].ids ?? [];
232+ /** Whether this device answers for a seat: a hand it holds, or — at the
233+ * table — a hand nobody has taken away and the computer is not playing. */
234+ const playsHere = (seat: SeatId): boolean => {
235+ const held = holdersOf(seat);
236+ if (held.length > 0) return held.includes(session!.room.me.id);
237+ return isTable && !ctl.state.bots[seat];
238+ };
239+ const mine = SEATS.filter(playsHere);
240+
241+ /**
242+ * Turned so your own seat is the near edge — but only for a device holding
243+ * one hand and no table. Four people round a screen are sitting where they
244+ * are sitting, and turning the felt under them would move three of them.
245+ */
246+ const viewSeat: SeatId = !isTable && mine.length === 1 ? mine[0] : 0;
222247 const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
223248 // Everything the sound needs to know about where this screen is sitting, and
224249 // who else in the room is going to be talking. See castFor, above.
225250 earshot.current = {
226251 posOf,
227- mode: netMode,
228252 mySeat,
229- isScreen,
253+ isTable,
254+ handheld,
230255 voices: session?.voiceSeats ?? [false, false, false, false],
231256 };
232257
258+ // The one engine, so exactly one device runs the computer players, exactly
259+ // one writes the save, and undo belongs to the table rather than to a hand.
260+ useEffect(() => {
261+ game.persist = authority;
262+ }, [game, authority]);
263+ useEffect(() => {
264+ if (session) session.table.allowUndo = isTable;
265+ }, [session, isTable]);
266+
233267 // Names and stakes live in the game state as well as in the preferences —
234268 // saved games keep the settings they were played under.
235269 const commitSettings = (p: Prefs) => {
⋯ 47 unchanged lines
283317 // The browser hands the screen back on every reload, and this game is built
284318 // to be put down and picked up again. Starting a hand is a tap, and a tap is
285319 // the only place the request is allowed — so that is where it goes back on.
286- const startGame = (bots: boolean[]) => {
320+ const goFullscreen = () => {
287321 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
288- game.newGame(bots, prefs.names);
289322 };
290323
291324 /**
292- * 四人同桌 — the table itself. Four people at this screen, each hand face
293- * down under a tap, and any of them free to take their hand onto a phone
294- * from the QR in their own gear. Hotseat and 手機派對 used to be two buttons
295- * on this lobby and they were never two games: one was the other with the
296- * phones left in your pocket.
325+ * 開始. One button, because there is one game.
297326 *
298- * So the tiles are dealt here and now — a screen with no server behind it is
299- * still four people at a table, and nobody should watch a spinner to start
300- * playing — and the room is opened behind it. When it lands it inherits the
301- * hand in progress rather than dealing over the top of it, and the only
302- * thing that changes on screen is that the QRs start working. If it never
303- * lands, nothing does.
327+ * The tiles are dealt here and now, on this device, with nothing on the wire
328+ * — you at the bottom edge and the computer on the other three. That is not
329+ * a mode, it is a table with three chairs still empty, and it is the shape
330+ * every other arrangement grows out of: somebody sits down at this screen
331+ * and taps a computer's strip, somebody else scans a seat's QR onto their
332+ * phone, a friend three cities away opens the invite link. Each of those
333+ * swaps one hand, mid-game, and nothing is dealt again.
334+ *
335+ * Nothing here waits on a server, and a page that never reaches one still
336+ * plays a full game.
304337 */
305- const openTable = async (from?: GameState) => {
306- if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
307- if (from) game.restore(from);
308- else game.newGame(HOTSEAT_SEATS, prefs.names);
309- const s = await connect();
310- if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules, game.state);
338+ const startGame = () => {
339+ goFullscreen();
340+ game.newGame(OPENING_SEATS, prefs.names);
341+ // Started inside a room somebody had already opened and left empty: the
342+ // table it was waiting for is this one.
343+ session?.publishTable(prefs.names);
311344 };
312345 const resumeGame = () => {
313- // A saved game the computer was playing stays a solo game; a saved table
314- // opens its room again, so the phones that were on it can come back.
315- if (saved!.state.bots.some(Boolean)) {
316- if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
317- game.restore(saved!.state);
318- } else {
319- void openTable(saved!.state);
320- }
346+ goFullscreen();
347+ game.restore(saved!.state);
348+ session?.publishTable(prefs.names);
321349 };
322350
323- // Online is still its own thing: everybody on their own device, nobody
324- // sharing a screen, and a seat lobby before anything is dealt. Whoever
325- // creates the room is its host and may open a mode; a visitor who follows
326- // the link into a room where nothing has started yet waits for the choice.
327- const openOnline = async () => {
328- const s = await connect();
329- if (s?.isHost && !s.mode) s.openOnlineLobby();
351+ /**
352+ * Open the room around this table, so a phone can be pointed at it. The
353+ * first thing that needs one asks for it — the invite QR, or a seat being
354+ * sent to a phone — and until then this page has said nothing to any server.
355+ */
356+ const openRoom = async (): Promise<NetSession | null> => (session ? session : connect(true));
357+ /** Take a hand onto a phone: open the room if it is not open, then put that
358+ * seat's own QR up in its own gear, facing its own edge. */
359+ const toPhone = async (seat: SeatId) => {
360+ setMenuSeat(seat);
361+ await openRoom();
362+ };
363+ /** A hand changing hands between a person at this screen and the computer.
364+ * Networked or not, it is the same one call on the same one engine. */
365+ const setSeatBot = (seat: SeatId, bot: boolean) => {
366+ if (session) session.setBot(seat, bot);
367+ else game.setBot(seat, bot);
330368 };
331369
332370 // Preferences are written straight through, so a mute survives a refresh.
⋯ 21 unchanged lines
354392
355393 // Tell the room whether this device is going to say anything, and say so
356394 // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a
357- // phone's audio, the tab going to sleep. A party table only stops calling a
358- // seat's tiles once the phone on that seat has claimed the job.
395+ // phone's audio, the tab going to sleep.
396+ //
397+ // Only a phone claims the job. A phone is small enough to be in somebody's
398+ // hand and a hand is in the room, so the table can safely go quiet for that
399+ // seat and let the 碰 come out of it. A friend playing from their own laptop
400+ // three cities away is not in the room and never takes the words away from
401+ // anybody's table.
359402 useEffect(() => {
360403 if (!session) return;
361- const tell = () => session.reportVoice(voiceLive());
404+ const tell = () => session.reportVoice(voiceLive() && compact);
362405 tell();
363406 return onAudioChange(tell);
364- }, [session, prefs.sound, prefs.voice]);
365- useEffect(() => game.onSound((cue) => play(cue, castFor(cue, earshot.current))), [game]);
366- // Watches the table for the whole session and only stirs when a seat the
367- // computer holds is the one being waited on. Idle in a networked game, where
368- // whichever device is the room's host runs its own AutoPlay.
407+ }, [session, compact, prefs.sound, prefs.voice]);
408+ // One subscription, on whichever table this page is drawing. Off the engine
409+ // when there is no room; off the room's own feed when there is, which on the
410+ // host is the engine again and everywhere else is what the host sent.
411+ useEffect(() => ctl.onSound((cue) => play(cue, castFor(cue, earshot.current))), [ctl]);
412+ // Watches the table and only stirs when a seat the computer holds is the one
413+ // being waited on. Exactly one device runs it: the one running the game, so
414+ // three phones watching a table do not each play its computers' hands.
415+ //
369416 // The computer waits for a thrown tile to land — and for whoever it landed on
370- // to finish saying so — before it reaches for it.
371- // `showingColliders` holds the computer still as well: the throwing sandbox
372- // is no use if the table plays on around it.
373- useEffect(
374- () => new AutoPlay(game, () => pool.busy || showingColliders()).start(),
375- [game, pool],
376- );
417+ // to finish saying so — before it reaches for it. `showingColliders` holds it
418+ // still as well: the throwing sandbox is no use if the table plays on around
419+ // it.
420+ useEffect(() => {
421+ if (!authority) return;
422+ return new AutoPlay(game, () => pool.busy || showingColliders()).start();
423+ }, [authority, game, pool]);
377424
378425 // ---- routing ------------------------------------------------------------
379- // A phone at the party table holds a hand, not the table.
380- if (session && netMode === 'party' && !isScreen) {
381- return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
382- }
383- if (session && netMode === 'online' && !session.started) {
384- return <OnlineLobby session={session} prefs={prefs} />;
426+ // A device in somebody else's room holds a hand, not a table: a phone shows
427+ // the hand and nothing else, and anything bigger shows the whole table
428+ // turned round to that seat's edge. Either way it picks a chair first.
429+ if (session && !isTable && session.open) {
430+ if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
431+ if (mine.length === 0) {
432+ return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />;
433+ }
385434 }
386435
387- if (!netActive && phase === 'lobby') {
436+ if (phase === 'lobby') {
388437 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
389- const savedSolo = saved?.state.bots.some(Boolean);
390- const netIdle = !session?.mode;
391438 return (
392439 <div className="lobby">
393440 {/* The title is already the bilingual pair: the name in Chinese, the
⋯ 11 unchanged lines
405452 {saved && (
406453 <div className="resume">
407454 <div className="resume-line">
408- {savedSolo ? t('單人', 'Solo') : t('同桌', 'Table')} ·{' '}
409455 {t(
410456 `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`,
411457 `${WIND_EN[round]} round, hand ${saved.state.handNumber}`,
⋯ 14 unchanged lines
426472 )}
427473
428474 <div className="lobby-buttons">
429- {saved && (
475+ {saved && authority && (
430476 <button className="btn big-btn" onClick={resumeGame}>
431477 <Bi zh="繼續對局" en="Resume" />
432478 </button>
433479 )}
434- <button
435- className={`btn big-btn ${saved ? 'ghost' : ''}`}
436- onClick={() => startGame(SOLO_SEATS)}
437- >
438- <Bi zh="單人對局" en="Single player" />
439- <em>
440- <BiText
441- zh="你坐東家,其餘三家由電腦代打"
442- en="you at 東, three computer players"
443- />
444- </em>
445- </button>
446- {/* One table, phones optional — see openTable. Not behind the room
447- check the online button is behind: this one deals first and asks
448- the server afterwards. */}
449- <button className="btn big-btn ghost" onClick={() => void openTable()}>
450- <Bi zh="四人同桌" en="Four at one table" />
480+ {/* One button, because there is one game. It deals you in against
481+ three computers and asks nothing of any server; everybody else
482+ arrives afterwards, one hand at a time, without it being dealt
483+ again. See startGame.
484+
485+ Hidden only in the one place it cannot work: standing in
486+ somebody else's room, where the table is theirs to deal. */}
487+ {authority && (
488+ <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={startGame}>
489+ <Bi zh="開始" en="Start" />
451490 <em>
452491 <BiText
453- zh="四個人共用一個螢幕,牌先蓋著,也可以掃碼用手機拿牌"
454- en="four people, one screen — hands stay face down until you tap, and any of them can move to a phone"
492+ zh="先發牌,其餘三家由電腦代打;有人要打,點他那一家坐下,或掃碼用手機拿牌"
493+ en="dealt straight away, computers on the other three — tap a seat to sit down at this screen, or scan into it from a phone"
455494 />
456495 </em>
457496 </button>
458- {(!session || (session.isHost && netIdle)) && (
459- <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}>
460- <Bi zh="線上對戰" en="Online game" />
461- <em>
462- <BiText zh="各自用自己的裝置" en="everyone joins from their own device" />
463- </em>
464- </button>
465497 )}
466- {session && !session.isHost && netIdle && (
498+ {session && !session.isHost && !session.open && (
467499 <p className="hint">
468500 <BiText
469- zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主選模式`}
470- en={`connected ${session.room.code} — waiting for the host to pick a mode`}
501+ zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主開局`}
502+ en={`connected ${session.room.code} — waiting for the host to deal`}
471503 />
472504 </p>
473505 )}
474506 </div>
475507
476508 {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>}
477- {/* Where the connection stands, in so many words. The room chrome is
478- not always there to say it, and a button that cannot work yet
479- should say why rather than doing nothing. */}
480- <p className="hint">
481- {connecting
482- ? t('連線中', 'connecting…')
483- : session
484- ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t(
485- '人',
486- session.playerCount === 1 ? 'player' : 'players',
487- )} · ${
488- session.isHost
489- ? t('你是房主', 'you host')
490- : t('房主在別台', 'host elsewhere')
491- }`
492- : roomFromUrl()
493- ? t('未連線', 'not connected')
494- : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')}
495- </p>
509+ {/* Where the connection stands, in so many words — but only when there
510+ is something to say. A game that has not asked for a room yet is
511+ not offline, it simply has not needed one. */}
512+ {(connecting || session || roomFromUrl()) && (
513+ <p className="hint">
514+ {connecting
515+ ? t('連線中', 'connecting…')
516+ : session
517+ ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t(
518+ '人',
519+ session.playerCount === 1 ? 'player' : 'players',
520+ )}`
521+ : t('未連線', 'not connected')}
522+ </p>
523+ )}
496524
497525 <div className="lobby-extras">
498526 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
⋯ 11 unchanged lines
510538 );
511539 }
512540
513- // What each seat is to this particular screen: whose buttons it shows, whose
514- // tiles it may turn over, and what stands in for the seats that live on
515- // other devices.
516- const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => {
517- if (!netActive) return { local: true };
518- const slot = session!.seats[seat];
519- const held = slot.ids.length;
520- if (netMode === 'party') {
521- return {
522- // A seat nobody has scanned is played right here, hotseat-style.
523- local: held === 0,
524- remote: {
525- // More phones than one on a hand is a pair playing it together.
526- holder:
527- held > 0
528- ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}`
529- : null,
530- onPeek: setQrPeek,
531- // The table has gone quiet for this seat on purpose: the phone on it
532- // is doing the calling.
533- speaks: session!.voiceSeats[seat] === true,
534- },
535- };
536- }
541+ /**
542+ * What each seat is to this particular screen: whose buttons it shows, whose
543+ * tiles it may turn over, and — because a hand can change hands mid-game —
544+ * what it offers to do about it.
545+ *
546+ * The three answers are the same three everywhere: the computer is playing
547+ * it, somebody at this screen is, or it has gone to a device of its own. Any
548+ * of them turns into any other without the tiles being dealt again, which is
549+ * the whole of what used to be three modes on a lobby.
550+ */
551+ const seatWho = (seat: SeatId): SeatWho | undefined => {
552+ // A hand on somebody's own device is theirs; the table's controls for the
553+ // other three are the table's business, not a visitor's.
554+ if (!isTable) return undefined;
555+ const slot = session?.seats[seat];
556+ const held = slot?.ids.length ?? 0;
557+ const bot = ctl.state.bots[seat];
537558 return {
538- local: seat === mySeat,
539- // A deserted human seat: the host's screen offers it to the computer.
540- remote:
541- held === 0 && !ctl.state.bots[seat] && session!.isHost
542- ? { holder: null, onHandToBot: () => session!.handToBot(seat) }
543- : undefined,
559+ // More devices than one on a hand is a pair playing it together.
560+ holder:
561+ held > 0 ? `${slot!.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` : null,
562+ // Its device went. The hand is being kept for a moment in case that was
563+ // a pocket rather than a goodbye — see `vacate` in net/session.ts.
564+ waiting: session?.isWaiting(seat) === true,
565+ onPeek: session ? setQrPeek : undefined,
566+ // The table has gone quiet for this seat on purpose: the phone on it is
567+ // doing the calling.
568+ speaks: session?.voiceSeats[seat] === true,
569+ // Somebody has sat down in front of this edge and wants the hand the
570+ // computer has been playing. One tap, mid-hand, tiles and all.
571+ onSit: held === 0 && bot ? () => setSeatBot(seat, false) : undefined,
572+ // And back again — nobody is going to play it, or nobody wants to.
573+ onBot: held === 0 && !bot ? () => setSeatBot(seat, true) : undefined,
574+ onPhone: held === 0 ? () => void toPhone(seat) : undefined,
544575 };
545576 };
546577
547- // Whether this screen is one several people are sitting round, in which case
548- // every hand played at it starts face down: your neighbour can see your
549- // tiles, and the answer used to be a piece of card laid over your strip.
550- //
551- // A table with a room open is always one of those, however few hands are
552- // still being played on it — the people whose hands went to their phones did
553- // not leave the room, they are sitting right there looking at the screen.
554- // Off where the screen belongs to one player: solo, and online, where
555- // everybody has a device of their own.
556- const sharing = netActive
557- ? netMode === 'party'
558- : SEATS.filter((s) => seatProps(s).local && !ctl.state.bots[s]).length > 1;
578+ /**
579+ * Whether this screen is one several people are sitting round, in which case
580+ * every hand played at it starts face down: your neighbour can see your
581+ * tiles, and the answer used to be a piece of card laid over your strip.
582+ *
583+ * It is simply how many hands this device is playing. One is one person —
584+ * playing the computer, or holding their own hand on their own device — and
585+ * covering your own tiles from yourself is a tap in the way. Two or more is
586+ * a screen with more than one person's hand on it, whatever else is true
587+ * about the room.
588+ */
589+ const sharing = mine.length > 1;
559590 // Where a hand goes when its player would rather hold it themselves. Only
560- // where there is a room to go to.
591+ // once there is a room for it to go to; before that the gear offers to open
592+ // one instead.
561593 const seatUrl = (seat: SeatId) =>
562- session && netMode === 'party'
563- ? roomLink(session.room.code, session.room.publicBase, seat)
564- : undefined;
594+ session ? roomLink(session.room.code, session.room.publicBase, seat) : undefined;
565595
566596 return (
567597 <div className="table">
598+ {/* The wire has gone quiet. Said out loud on the table too, not only on
599+ the lobby it is easy to have left behind: a hand that has stopped
600+ answering should say why rather than looking like a game that has
601+ stopped. The room reconnects on its own, and this clears when state
602+ starts flowing again. */}
603+ {session?.err && <div className="net-warn">{session.err}</div>}
568604 {SEATS.map((s) => {
569- const sp = seatProps(s);
570605 return (
571606 <Seat
572607 key={s}
573608 game={ctl}
574609 seat={s}
575610 pos={posOf(s)}
576- local={sp.local}
611+ local={playsHere(s)}
577612 shared={sharing}
578- remote={sp.remote}
613+ who={seatWho(s)}
579614 compact={compact}
580615 pool={compact ? undefined : pool}
581616 onHelp={() => setHelpSeat(posOf(s))}
⋯ 11 unchanged lines
593628 starts, because from then on the middle is where the tiles go and a
594629 phone is asked for from the seat's own gear instead. It comes back
595630 while any seat's 📱 tag is held down, for a player who lost theirs. */}
596- {netMode === 'party' &&
631+ {session &&
632+ isTable &&
597633 !compact &&
598634 (qrPeek ||
599- (session!.seats.some((s) => s.ids.length === 0) &&
635+ (session.seats.some((s) => s.ids.length === 0) &&
600636 ctl.state.players.every((p) => p.discards.length === 0))) && (
601637 <div className="qr-dock">
602638 <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
⋯ 8 unchanged lines
611647 prefs={prefs}
612648 compact={compact}
613649 viewSeat={viewSeat}
614- preferSeat={netMode === 'online' ? mySeat : null}
650+ preferSeat={isTable ? null : mySeat}
615651 onPrefs={setPrefs}
616652 onSettings={() => setSettingsOpen(true)}
617653 />
⋯ 4 unchanged lines
622658 prefs={prefs}
623659 pos={posOf(menuSeat)}
624660 seatUrl={seatUrl(menuSeat)}
661+ who={seatWho(menuSeat)}
625662 compact={compact}
626663 onPrefs={setPrefs}
627664 onSettings={() => setSettingsOpen(true)}
⋯ 20 unchanged lines
modifiedsrc/net/protocol.ts+28 −10
⋯ 13 unchanged lines
1414 * engine methods a button would have called. The engine itself never knows any
1515 * of this is happening — the same trick `autoplay.ts` plays, stretched over a
1616 * network.
17+ *
18+ * There is one kind of game. A room is not opened until somebody wants a
19+ * phone in it, and what it carries after that is only ever the same four
20+ * questions: who is holding each hand, where the hands nobody holds are being
21+ * played, and what the table looks like. Solo, four round a laptop and four in
22+ * four cities are all arrangements of those, not modes — see `App.tsx`.
1723 */
1824
19-/** The two ways a room can be playing. */
20-export type NetMode = 'online' | 'party';
21-
2225 /**
2326 * Who holds a seat. A list, not a single id: a seat is a hand, and nothing
2427 * stops two people huddling over one hand — a pair playing together from two
⋯ 9 unchanged lines
3437
3538 /** The shared room state, as the host writes it. */
3639 export interface SharedKeys {
37- mode: NetMode;
3840 seats: SeatSlot[];
39- /** The whole engine state. null while the room is still in its lobby. */
41+ /**
42+ * The device the table is being played at: the one that opened the room, and
43+ * the one that goes on playing every seat no phone has taken. '' once it has
44+ * left, which is what tells the room those seats have nobody behind them and
45+ * should go to the computer — see `armStranded` in session.ts.
46+ *
47+ * It is not a mode. A laptop with four people round it and a phone with one
48+ * are the same thing here: whichever device the game was started on is where
49+ * the unclaimed hands are played, and every other device holds a hand.
50+ */
51+ screen: string;
52+ /** The whole engine state. Never null in practice: a room is opened around a
53+ * table that is already being played at. */
4054 game: GameState | null;
4155 /**
4256 * Which seats have a device on them that will say their calls out loud —
4357 * a phone in somebody's hand, with sound and 報牌 on and the hardware awake.
4458 *
45- * The table reads this and shuts up for those seats: at a party table all
46- * five devices are in the same room, so the 碰 should come out of the hand
47- * of the person calling it and not out of the laptop in the middle. Held by
48- * the host, which is the only thing that hears every phone.
59+ * Only a phone claims it — the device is small enough to be in a hand, and
60+ * a hand is in the room. The screen reads this and shuts up for those seats,
61+ * so the 碰 comes out of the hand of the person calling it and not out of
62+ * the laptop in the middle. Held by the host, which is the only thing that
63+ * hears every device.
4964 */
5065 voices: boolean[];
5166 }
⋯ 7 unchanged lines
5974 | { kind: 'addkong'; tile: Tile }
6075 | { kind: 'selfDraw' }
6176 | { kind: 'nextHand' }
62- | { kind: 'newGame' };
77+ | { kind: 'newGame' }
78+ /** Give this hand to the computer, or take it back off it. Only ever about a
79+ * hand nobody is holding — see `handleAct`. */
80+ | { kind: 'bot'; on: boolean };
6381
6482 export interface ActMsg {
6583 seat: SeatId;
⋯ 91 unchanged lines
modifiedsrc/net/room.ts+10 −0
⋯ 98 unchanged lines
9999 private joinCbs = new Set<(p: PlayerInfo) => void>();
100100 private leaveCbs = new Set<(p: PlayerInfo) => void>();
101101 private hostCbs = new Set<(host: PlayerInfo | null) => void>();
102+ private resumeCbs = new Set<() => void>();
102103 private errorCbs = new Set<(e: RoomError) => void>();
103104 private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>();
104105
⋯ 75 unchanged lines
180181 onHostChange(cb: (host: PlayerInfo | null) => void): Unsub {
181182 return sub(this.hostCbs, cb);
182183 }
184+ /**
185+ * The connection came back and the room was rejoined. Whatever we meant to
186+ * say while it was down never went, and what the server holds is as old as
187+ * the blip — so whoever owns the state should say it again.
188+ */
189+ onResume(cb: () => void): Unsub {
190+ return sub(this.resumeCbs, cb);
191+ }
183192 onError(cb: (e: RoomError) => void): Unsub {
184193 return sub(this.errorCbs, cb);
185194 }
⋯ 111 unchanged lines
297306 if (p.id !== this.me.id && !before.some((q) => q.id === p.id))
298307 for (const cb of this.joinCbs) cb(p);
299308 if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host);
309+ for (const cb of this.resumeCbs) cb();
300310 }
301311
302312 private emitError(e: RoomError) {
⋯ 13 unchanged lines
modifiedsrc/net/session.ts+204 −139
1-import { AutoPlay } from '../game/autoplay';
21 import type { NetThrow } from '../game/ctl';
3-import type { GameState, SoundEvent } from '../game/engine';
2+import type { Game, SoundEvent } from '../game/engine';
43 import type { Tile } from '../game/tiles';
5-import type { Rules, SeatId } from '../game/types';
4+import type { SeatId } from '../game/types';
65 import {
76 EMPTY_SEATS,
87 EV_ACT,
⋯ 2 unchanged lines
1110 EV_VOICE,
1211 type ActMsg,
1312 type FxMsg,
14- type NetMode,
1513 type SeatMsg,
1614 roomFromUrl,
1715 type SeatSlot,
⋯ 5 unchanged lines
2321 const SEATS: SeatId[] = [0, 1, 2, 3];
2422
2523 /**
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+ */
30+const EMPTY_SEAT_GRACE = 15_000;
31+
32+/**
2633 * One device's connection to a room, and everything that follows from it.
2734 *
2835 * The relay elects a host — the earliest joiner still connected — and the host
⋯ 3 unchanged lines
3239 * already current — it starts its own engine from what it was just watching
3340 * and the game carries on.
3441 *
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`.
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.
3747 */
38-export type NetRole = 'player' | 'screen';
39-
4048 export class NetSession {
4149 readonly room: Room;
4250 readonly table: NetTable;
43- role: NetRole = 'player';
4451 /** Latest connection trouble worth showing, or null. */
4552 err: string | null = null;
4653 /**
⋯ 2 unchanged lines
4956 */
5057 onThrowFx: (seat: SeatId, t: NetThrow) => void = () => {};
5158
52- private autoplay: AutoPlay | null = null;
53- private stopAutoplay: (() => void) | null = null;
59+ /** Hands whose device has gone, counting down to the computer taking them. */
60+ private waiting = new Map<SeatId, ReturnType<typeof setTimeout>>();
5461 /** Who in the room has said they can talk. Kept by the host; see `voices`. */
5562 private talkers = new Map<string, boolean>();
5663 /** The last thing we told the host about ourselves, so we only say changes. */
5764 private toldVoice: boolean | null = null;
5865 /** Whether *we* can talk, kept so a new host can be told the same thing. */
5966 private myVoice = false;
60- private busy: () => boolean = () => false;
6167 private listeners = new Set<() => void>();
6268 private version = 0;
6369 private unsubs: (() => void)[] = [];
6470
65- private constructor(room: Room) {
71+ private constructor(room: Room, engine: Game) {
6672 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- });
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+ );
7281
7382 const u = this.unsubs;
7483 u.push(
⋯ 1 unchanged line
7685 // State flowing means the connection is back, whatever it said before.
7786 this.err = null;
7887 this.table.adopt(this.sharedGame());
79- this.syncAutoplay();
88+ this.reviewSeats();
8089 this.bump();
8190 }),
8291 room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)),
⋯ 4 unchanged lines
8796 room.onLeave((p) => {
8897 if (this.isHost) {
8998 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 });
90103 this.vacate(p.id);
104+ this.armStranded();
91105 this.publishVoices();
92106 }
93107 this.bump();
94108 }),
95109 room.onHostChange(() => {
96110 // 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();
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();
100119 // What it does not inherit is who told the old host they could talk,
101120 // so everybody says it again.
102121 this.toldVoice = null;
103122 this.reportVoice(this.myVoice);
104123 this.bump();
105124 }),
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+ }),
106133 room.onError((e) => {
107134 this.err = `${e.message} — ${e.hint}`;
108135 this.bump();
109136 }),
110137 // The host's own engine is the source of truth: publish every change.
111138 this.table.engine.subscribe(() => {
112- if (this.isHost && this.mode) this.publishGame();
113- this.syncAutoplay();
139+ if (this.isHost && this.sharedGame() !== null) this.publishGame();
140+ this.reviewSeats();
114141 }),
115142 this.table.engine.onSound((cue) => {
116143 if (!this.isHost) return;
⋯ 19 unchanged lines
136163 room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`));
137164 }
138165
139- /** Join the room in the page URL, or create a fresh one. */
140- static async join(): Promise<NetSession> {
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> {
141172 const room = await joinRoom({ room: roomFromUrl() ?? undefined });
142173 // The room code goes into the address bar: the page's own URL becomes the
143174 // invite link, and a reload walks back into the same room — and, with the
⋯ 5 unchanged lines
149180 } catch {
150181 // A sandbox that refuses history rewrites still gets to play.
151182 }
152- return new NetSession(room);
183+ return new NetSession(room, engine);
153184 }
154185
155186 leave() {
156187 for (const u of this.unsubs) u();
157188 this.unsubs = [];
158- this.stopAutoplay?.();
159- this.stopAutoplay = null;
189+ for (const t of this.waiting.values()) clearTimeout(t);
190+ this.waiting.clear();
160191 this.room.leave();
161192 }
162193
⋯ 14 unchanged lines
177208 get isHost() {
178209 return this.room.isHost;
179210 }
180- get mode(): NetMode | null {
181- const m = this.room.state.mode;
182- return m === 'online' || m === 'party' ? m : null;
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;
183223 }
184224 get seats(): SeatSlot[] {
185225 const s = this.room.state.seats;
186226 return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS;
187227 }
188228 /** 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. */
229+ * table dealt it with. */
190230 private homeName(seat: SeatId): string {
191231 const h = this.room.state.homes;
192232 return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : '';
⋯ 6 unchanged lines
199239 for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i;
200240 return null;
201241 }
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';
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';
206246 }
207247 /** Everyone here, for the lobby's presence line. */
208248 get playerCount() {
209249 return this.room.players.length;
210250 }
211251
212- // ---- lobby / starting --------------------------------------------------
252+ // ---- opening the room --------------------------------------------------
213253 /**
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.
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.
217266 */
218- openOnlineLobby() {
267+ publishTable(names: string[]) {
219268 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] ?? '' }));
220272 this.room.setState({
221- mode: 'online',
222- seats: structuredClone(EMPTY_SEATS) as never,
223- homes: ['', '', '', ''] as never,
273+ seats: seats as never,
274+ homes: seats.map((q) => q.name) as never,
224275 voices: [false, false, false, false] as never,
276+ screen: this.room.me.id as never,
277+ game: structuredClone(this.table.engine.state) as never,
225278 });
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);
279+ this.bump();
239280 }
240281
241282 /**
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.
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.
252286 */
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- }
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);
275292 }
276293
277294 /** Ask for a seat — a numbered one, or any free one. */
⋯ 2 unchanged lines
280297 else this.room.send(EV_SEAT, { seat } as never);
281298 }
282299
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-
295300 // ---- host duties -------------------------------------------------------
296301 /**
297302 * A claim never fails. A seat is a hand, not a chair: two phones may hold
⋯ 15 unchanged lines
313318 if (seats[seat].ids.length === 1 && name) seats[seat].name = name;
314319 this.settleSeats(seats);
315320
316- // Mid-game, the seat comes back from the computer (or the common screen)
317- // to the person who claimed it.
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);
318325 if (this.sharedGame() !== null) {
319326 this.table.engine.setBot(seat, false);
320327 if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name);
⋯ 1 unchanged line
322329 this.bump();
323330 }
324331
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+ */
325343 private vacate(id: string) {
326344 const seats = structuredClone(this.seats);
327- let changed = false;
345+ const emptied: SeatId[] = [];
328346 for (const i of SEATS) {
329347 const kept = seats[i].ids.filter((x) => x !== id);
330- if (kept.length !== seats[i].ids.length) changed = true;
348+ if (kept.length === seats[i].ids.length) continue;
331349 seats[i].ids = kept;
350+ if (kept.length === 0) emptied.push(i);
332351 }
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.
352+ if (emptied.length === 0) return;
353+ this.settleSeats(seats);
354+ for (const i of emptied) this.armWait(i);
337355 }
338356
339- /** Publish the seat map, giving emptied hands their own names back. */
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+ */
340418 private settleSeats(seats: SeatSlot[]) {
341- const party = this.mode === 'party';
342419 for (const i of SEATS) {
343420 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- }
421+ seats[i].name = this.homeName(i);
422+ if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name);
353423 }
354424 this.room.setState({ seats: seats as never });
355425 // A hand that changed hands changed who speaks for it.
⋯ 3 unchanged lines
359429 private handleAct(msg: ActMsg, from: string) {
360430 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
361431 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).
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).
365436 const holders = this.seats[seat]?.ids ?? [];
366- if (holders.length > 0 && !holders.includes(from)) return;
437+ const table = this.screenId || this.room.me.id;
438+ if (holders.length > 0 ? !holders.includes(from) : from !== table) return;
367439 const eng = this.table.engine;
368440 const act = msg.act;
369441 switch (act.kind) {
⋯ 27 unchanged lines
397469 case 'newGame':
398470 eng.newGame(eng.state.bots);
399471 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;
400478 }
401479 }
402480
⋯ 41 unchanged lines
444522 */
445523 private publishVoices() {
446524 if (!this.isHost) return;
447- const screen = this.mode === 'party' ? this.room.me.id : '';
525+ const screen = this.screenId;
448526 const voices = SEATS.map((i) =>
449527 this.seats[i].ids.some((id) => id !== screen && this.talkers.get(id) === true),
450528 );
⋯ 17 unchanged lines
468546 // The engine mutates its state in place; the room keeps what it is handed,
469547 // so it gets a copy that will hold still.
470548 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- }
484549 }
485550 }
modifiedsrc/net/table.ts+6 −5
⋯ 20 unchanged lines
2121 const PENDING_TTL = 3000;
2222
2323 /**
24- * The online table: the same surface the engine offers (`TableCtl`), backed by
25- * a `Game` that is either the real one or a mirror of the host's.
24+ * The networked table: the same surface the engine offers (`TableCtl`), put
25+ * around the one `Game` this page has. There is no second engine and no copy —
26+ * opening a room is a wrapper going on, and a hand in progress carries straight
27+ * through it.
2628 *
2729 * On the host the inner engine is authoritative and calls go straight into it.
2830 * Everywhere else the inner engine is a mirror kept current by `adopt`, and a
⋯ 29 unchanged lines
5860
5961 private sounds = new Set<(cue: SoundCue) => void>();
6062
61- constructor(wire: TableWire) {
63+ constructor(wire: TableWire, engine: Game) {
6264 this.wire = wire;
63- this.engine = new Game();
64- this.engine.persist = false;
65+ this.engine = engine;
6566 this.engine.subscribe(() => this.bump());
6667 }
6768
⋯ 197 unchanged lines
modifiedsrc/styles.css+39 −0
⋯ 795 unchanged lines
796796 .btn.discard {
797797 background: linear-gradient(180deg, #fff2cf, #e0c070);
798798 }
799+/* Somebody pulling up a chair at a hand the computer has been playing. It is
800+ the only button on that strip, so it may as well be the warm one — and it is
801+ the same gold as 摸牌, because both are the table's way of saying this edge
802+ is yours now. */
803+.btn.sit {
804+ background: rgba(217, 180, 92, 0.18);
805+ border-color: rgba(217, 180, 92, 0.55);
806+ color: #f6f0e2;
807+}
799808
800809 /* ---------- the action buttons are tiles ---------- */
801810 /* 打出, 碰, 吃, 槓, 胡, 過 are all things you do *with* a tile, and they sit in
⋯ 1779 unchanged lines
25812590 user-select: none;
25822591 touch-action: none;
25832592 }
2593+/* The device holding this hand has gone quiet and the hand is being kept for
2594+ it. Amber rather than red: nothing is lost, and in a few seconds the
2595+ computer picks the tiles up so the table can carry on. */
2596+.nameplate .tag.waiting {
2597+ background: rgba(217, 180, 92, 0.28);
2598+ border-color: rgba(217, 180, 92, 0.6);
2599+ padding-inline: 5px;
2600+}
25842601 /* This hand is calling for itself, out of the phone holding it. Sits next to
25852602 the 📱 tag and reads as part of it, which is what it is. */
25862603 .nameplate .tag.voice {
⋯ 302 unchanged lines
28892906 font-size: 11px;
28902907 opacity: 0.75;
28912908 }
2909+
2910+/* ---------- the wire, when it goes ---------- */
2911+/* One line across the top of the felt. The room is already reconnecting on its
2912+ own, so this says what is happening rather than asking for anything, and it
2913+ goes away by itself when state starts flowing again. Above the tiles and out
2914+ of the way of every strip, which all sit against their own edge. */
2915+.net-warn {
2916+ position: absolute;
2917+ top: 6px;
2918+ left: 50%;
2919+ transform: translateX(-50%);
2920+ z-index: 60;
2921+ max-width: 70vw;
2922+ padding: 4px 12px;
2923+ border-radius: 999px;
2924+ background: rgba(20, 40, 30, 0.82);
2925+ border: 1px solid rgba(217, 180, 92, 0.5);
2926+ color: rgba(246, 240, 226, 0.85);
2927+ font-size: 12px;
2928+ text-align: center;
2929+ pointer-events: none;
2930+}
modifiedsrc/ui/Controller.tsx+39 −30
⋯ 23 unchanged lines
2424 }
2525
2626 /**
27- * The phone at the party table: just your own tiles, held in landscape, with
28- * the common screen doing the table's share of the talking. What it keeps of
29- * the full table is the things that are yours — the buttons a claim window
30- * owes you, the throw, and your voice. Flick a tile up off the top of the
31- * phone and it leaves your hand here, crosses the wire, and comes flying in
32- * from your edge of the shared screen.
27+ * A hand held on a phone: just your own tiles, held in landscape, with the
28+ * table doing the table's share of the talking. What it keeps of the full
29+ * table is the things that are yours — the buttons a claim window owes you,
30+ * the throw, and your voice. Flick a tile up off the top of the phone and it
31+ * leaves your hand here, crosses the wire, and comes flying in from your edge
32+ * of the shared screen.
3333 *
34- * Your voice, because the five devices at a party table are all in the same
35- * room: with sound on, this phone is what says 碰 when you call one, and the
36- * common screen stops saying it for you. Mute the phone and the table takes
37- * the job back — see `net/session.ts` for how it is told.
34+ * Your voice, because a phone is in somebody's hand and a hand is in the room:
35+ * with sound on, this phone is what says 碰 when you call one, and the table
36+ * stops saying it for you. Mute the phone and the table takes the job back —
37+ * see `net/session.ts` for how it is told.
3838 */
3939 export function Controller({ session, prefs, onPrefs }: CtlProps) {
4040 const seat = session.mySeat;
41-
42- // The QR carries which seat it was stuck to; a phone that arrived some other
43- // way — or lost the parameter on the way in — picks one by hand below.
44- const [asked, setAsked] = useState(false);
45- useEffect(() => {
46- if (asked || seat !== null) return;
47- const want = seatFromUrl();
48- if (want !== null) {
49- setAsked(true);
50- session.claimSeat(want);
51- }
52- }, [session, seat, asked]);
53-
54- if (seat === null) {
55- return <SeatPick session={session} joining={asked} prefs={prefs} onPrefs={onPrefs} />;
56- }
41+ if (seat === null) return <SeatPick session={session} prefs={prefs} onPrefs={onPrefs} />;
5742 return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />;
5843 }
5944
60-function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) {
45+/**
46+ * Which hand you are taking. Shown to any device that has walked into a room
47+ * and is not the table it was opened around — a phone off a QR, a friend off
48+ * the invite link.
49+ *
50+ * Every seat is offered, not only the empty ones. A hand the computer is
51+ * playing is the ordinary way in — that is what the three chairs nobody has
52+ * taken are — and a hand somebody else is already holding can be sat down at
53+ * too, which is how a pair play one hand off two devices.
54+ */
55+export function SeatPick({ session, prefs, onPrefs }: CtlProps) {
6156 const t = useT();
6257 const lang = useLang();
6358 const seats = session.seats;
6459 const s = session.table.state;
60+
61+ // A seat's own QR carries which hand it was stuck to, so scanning it lands
62+ // on that chair without anybody having to say which one they are in. The
63+ // table's shared QR names none, and then the choice is made here.
64+ const [asked, setAsked] = useState(false);
65+ const want = seatFromUrl();
66+ useEffect(() => {
67+ if (asked || want === null) return;
68+ setAsked(true);
69+ session.claimSeat(want);
70+ }, [session, want, asked]);
71+
6572 return (
6673 <div className="ctl ctl-pick">
6774 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
6875 <p className="sub">
69- {joining
76+ {asked
7077 ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}`
71- : t('選一個座位', 'pick your seat', ' · ')}
78+ : t('選一家', 'pick a hand', ' · ')}
7279 </p>
7380 <div className="pick-grid">
7481 {SEATS.map((i) => {
⋯ 5 unchanged lines
8087 <em>
8188 {held > 0
8289 ? `${seats[i].name} — ${t('一起打', 'join this hand')}`
83- : seats[i].name || t('空位', 'open')}
90+ : s.bots[i]
91+ ? `${t('電腦代打中', 'the computer has it')} — ${t('接手', 'take over')}`
92+ : seats[i].name || t('在桌上', 'played at the table', ' · ')}
8493 </em>
8594 </button>
8695 );
⋯ 254 unchanged lines
modifiedsrc/ui/Hand.tsx+1 −1
⋯ 301 unchanged lines
302302 className={`hand ${onFaceUp ? 'covered' : ''}`}
303303 role={onFaceUp ? 'button' : undefined}
304304 tabIndex={onFaceUp ? 0 : undefined}
305- aria-label={onFaceUp ? t('看牌', 'look at your hand', ' · ') : undefined}
305+ aria-label={onFaceUp ? t('看牌', 'look at this hand', ' · ') : undefined}
306306 onClick={onFaceUp}
307307 onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())}
308308 >
⋯ 32 unchanged lines
deletedsrc/ui/OnlineLobby.tsx+0 −100
1-import { useEffect, useRef } from 'react';
2-import type { Prefs } from '../game/prefs';
3-import { WIND_EN, WIND_NAMES } from '../game/tiles';
4-import type { SeatId } from '../game/types';
5-import type { NetSession } from '../net/session';
6-import { Bi, BiText } from './Bi';
7-import { useLang, useT } from './lang';
8-
9-const SEATS: SeatId[] = [0, 1, 2, 3];
10-
11-/**
12- * The room before the deal: who is here, who is sitting where, and — for the
13- * host — the button that starts it. Seats nobody takes go to the computer, so
14- * two friends and two bots is a game, not a wait.
15- */
16-export function OnlineLobby({ session, prefs }: { session: NetSession; prefs: Prefs }) {
17- const t = useT();
18- const lang = useLang();
19- const seats = session.seats;
20- const me = session.room.me.id;
21-
22- // Walking in unseated: take the first free chair rather than making everyone
23- // pick one. Moving is one tap on another seat — including one somebody
24- // already holds, which is how two people share a hand.
25- const claimed = useRef(false);
26- useEffect(() => {
27- if (claimed.current || session.mySeat !== null) return;
28- if (seats.some((s) => s.ids.length === 0)) {
29- claimed.current = true;
30- session.claimSeat(null);
31- }
32- }, [session, seats]);
33-
34- const seated = seats.filter((s) => s.ids.length > 0).length;
35-
36- return (
37- <div className="lobby">
38- <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
39- <p className="sub">
40- {t('線上對戰', 'online game', ' · ')} · {session.playerCount}{' '}
41- {t('人在房', 'in the room')}
42- </p>
43- <p className="hint">
44- <BiText
45- zh="把這頁的連結傳給朋友,他們一開就進同一房。"
46- en="Share this page's invite link — anyone who opens it lands at this table."
47- />
48- </p>
49-
50- <div className="pick-grid">
51- {SEATS.map((i) => {
52- const slot = seats[i];
53- const mine = slot.ids.includes(me);
54- const held = slot.ids.length;
55- return (
56- <button
57- key={i}
58- className={`btn big-btn ${mine ? '' : 'ghost'}`}
59- onClick={() => !mine && session.claimSeat(i)}
60- >
61- {t(WIND_NAMES[i], WIND_EN[i])}
62- <em>
63- {held === 0
64- ? t('空位,開局時由電腦代打', 'open — a bot if nobody sits', ' · ')
65- : mine
66- ? `${slot.name || t('你', 'you')}${
67- held > 1 ? ` +${held - 1}` : ''
68- } · ${t('你', 'you')}`
69- : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · ${t('可同坐', 'join them')}`}
70- </em>
71- </button>
72- );
73- })}
74- </div>
75-
76- <div className="lobby-buttons">
77- {session.isHost ? (
78- <button
79- className="btn big-btn"
80- disabled={seated === 0}
81- onClick={() => session.startOnline(prefs.rules)}
82- >
83- <Bi
84- zh="開局"
85- en="Deal"
86- sub={
87- seated < 4
88- ? t(`${4 - seated} 個電腦`, `${4 - seated} bots fill in`)
89- : undefined
90- }
91- />
92- </button>
93- ) : (
94- <p className="hint">{t('等房主開局', 'waiting for the host to deal', ' · ')}</p>
95- )}
96- </div>
97- {session.err && <p className="hint">{session.err}</p>}
98- </div>
99- );
100-}
modifiedsrc/ui/Seat.tsx+70 −26
⋯ 11 unchanged lines
1212 import { TileView } from './TileView';
1313
1414 /**
15- * A seat that exists somewhere else — a phone at the party table, or an online
16- * player. What of it shows on this screen is decided here.
15+ * Who is playing this hand, and what the table can do about it.
16+ *
17+ * There are three answers and they are the same three everywhere: the computer
18+ * has it, somebody at this screen has it, or it has gone onto a device of its
19+ * own. Any of them turns into any other mid-game — that is what the callbacks
20+ * are — and none of them redeals anything. Only the device the table is being
21+ * played at gets one of these; a visitor holding one hand has no business
22+ * moving the other three about.
1723 */
18-export interface RemoteSeat {
24+export interface SeatWho {
1925 /** Who holds it from their own device, or null while nobody does. */
2026 holder?: string | null;
21- /** Party screen: holding the 📱 tag re-shows the table's shared join QR,
22- * for the player who lost theirs. The QR itself lives in the table's
23- * corner — see App — because the strip clips its own contents. */
27+ /**
28+ * The device holding it has gone and the hand is being kept for a moment in
29+ * case that was a pocket rather than a goodbye. The computer takes it when
30+ * the moment is up, and gives it straight back to whoever comes for it.
31+ */
32+ waiting?: boolean;
33+ /** Holding the 📱 tag re-shows the table's shared join QR, for the player
34+ * who lost theirs. The QR itself lives in the table's corner — see App —
35+ * because the strip clips its own contents. */
2436 onPeek?: (show: boolean) => void;
25- /** Online host: the seat's player is gone; offer it to the computer. */
26- onHandToBot?: () => void;
37+ /** Somebody has sat down at this edge: take the hand off the computer. */
38+ onSit?: () => void;
39+ /** Nobody is going to play it here: give it to the computer. */
40+ onBot?: () => void;
41+ /** Take it onto a phone — opens the room first if there is not one yet. */
42+ onPhone?: () => void;
2743 /**
28- * Party table: this seat's phone has its sound on, so the calls for this
29- * hand are coming out of it rather than out of this screen. Worth saying on
30- * the nameplate, because it is the difference between the table having gone
31- * quiet for a seat and having gone wrong.
44+ * This seat's phone has its sound on, so the calls for this hand are coming
45+ * out of it rather than out of this screen. Worth saying on the nameplate,
46+ * because it is the difference between the table having gone quiet for a
47+ * seat and having gone wrong.
3248 */
3349 speaks?: boolean;
3450 }
⋯ 23 unchanged lines
5874 * because covering your own hand from yourself is just a tap in the way.
5975 */
6076 shared?: boolean;
61- remote?: RemoteSeat;
77+ who?: SeatWho;
6278 /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */
6379 compact?: boolean;
6480 /** The tiles in the middle. Absent on a phone, which has no middle. */
⋯ 10 unchanged lines
7591 pos = seat,
7692 local = true,
7793 shared,
78- remote,
94+ who,
7995 compact,
8096 pool,
8197 onHelp,
⋯ 81 unchanged lines
163179 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
164180 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
165181 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
166- // A deserted online seat, waiting for its person to come back — or for the
167- // host to hand it to the computer so the table can move.
168- const deserted = !!remote?.onHandToBot && !bot && remote.holder == null && live;
182+ // The computer is playing this hand and nobody has taken it off it. One tap
183+ // on the strip does — which is the whole of sitting down at this table.
184+ const sittable = !!who?.onSit && bot && live;
185+ // Its device has gone and the hand is being kept for it. Whoever is at the
186+ // table can stop waiting and hand it over now.
187+ const deserted = !!who?.waiting && !bot && live;
169188
170189 // Compact lays the discards out as one line that scrolls rather than a block
171190 // that wraps, so the newest tile — the only one anybody is looking for — has
⋯ 144 unchanged lines
316335 <Hand
317336 tiles={p.hand}
318337 faceDown={concealed}
319- onFaceUp={covered ? () => setShown(true) : undefined}
338+ /* Under the card, a tap lifts it. On a hand the computer is
339+ playing, the same tap is somebody pulling up a chair: the hand
340+ becomes theirs and turns over in the one gesture, which is as
341+ much ceremony as sitting down at a table ever gets. */
342+ onFaceUp={covered ? () => setShown(true) : sittable ? who!.onSit : undefined}
320343 drawnIndex={drawnIndex}
321344 selected={sel}
322345 canDiscard={myTurn}
⋯ 59 unchanged lines
382405 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
383406 <div className="waiting">{t('已回應', 'Responded')}</div>
384407 )}
408+ {/* Sitting down. It is on the strip rather than in the gear because
409+ it is the one thing somebody who has just pulled up a chair wants
410+ to do, and they should not have to find a menu to do it. The
411+ tiles come out from under the card in the same tap. */}
412+ {sittable && (
413+ <button className="btn ghost sit" onClick={who!.onSit}>
414+ <Bi zh="坐下" en="Sit here" />
415+ </button>
416+ )}
385417 {deserted && (
386- <button className="btn ghost" onClick={remote!.onHandToBot}>
418+ <button className="btn ghost" onClick={who!.onBot}>
387419 <Bi zh="電腦代打" en="Hand to computer" />
388420 </button>
389421 )}
⋯ 34 unchanged lines
424456 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
425457 </span>
426458 <span className="pname">{p.name}</span>
427- {remote?.holder != null && (
459+ {who?.waiting && (
460+ <span
461+ className="tag waiting"
462+ title={t(
463+ '這手牌的裝置斷了,先等一下,等不到就交給電腦',
464+ 'this hand lost its device — kept a moment, then the computer takes it',
465+ ' · ',
466+ )}
467+ >
468+ ⏳
469+ </span>
470+ )}
471+ {who?.holder != null && (
428472 <span
429473 className="tag phone"
430474 title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t(
⋯ 1 unchanged line
432476 'hold to show the QR again',
433477 ' · ',
434478 )}`}
435- onPointerDown={() => remote.onPeek?.(true)}
436- onPointerUp={() => remote.onPeek?.(false)}
437- onPointerCancel={() => remote.onPeek?.(false)}
438- onPointerLeave={() => remote.onPeek?.(false)}
479+ onPointerDown={() => who.onPeek?.(true)}
480+ onPointerUp={() => who.onPeek?.(false)}
481+ onPointerCancel={() => who.onPeek?.(false)}
482+ onPointerLeave={() => who.onPeek?.(false)}
439483 >
440- 📱 {remote.holder}
484+ 📱 {who.holder}
441485 </span>
442486 )}
443- {remote?.speaks && (
487+ {who?.speaks && (
444488 <span
445489 className="tag voice"
446490 title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')}
⋯ 238 unchanged lines
modifiedsrc/ui/TableMenu.tsx+55 −12
⋯ 4 unchanged lines
55 import { FullscreenToggle } from './FullscreenToggle';
66 import { useLang, useT } from './lang';
77 import { SEAT_ROT, SEAT_SLOT } from './rotation';
8+import type { SeatWho } from './Seat';
89 import { SeatQr } from './SeatQr';
910 import { SoundToggle } from './SoundToggle';
1011
⋯ 18 unchanged lines
2930 pos,
3031 compact,
3132 seatUrl,
33+ who,
3234 onPrefs,
3335 onSettings,
3436 onClose,
⋯ 4 unchanged lines
3941 pos: SeatId;
4042 compact?: boolean;
4143 /**
42- * The QR for taking *this hand* onto a phone, where there is a room to take
43- * it into. Absent when the table is on its own — no server, or an online
44- * game, where everybody is already on their own device.
44+ * The QR for taking *this hand* onto a phone, once there is a room to take
45+ * it into. Absent until then — `who.onPhone` is the button that opens one.
4546 */
4647 seatUrl?: string;
48+ /** Who is playing this hand, and what can be done about that. */
49+ who?: SeatWho;
4750 onPrefs: (p: Prefs) => void;
4851 onSettings: () => void;
4952 onClose: () => void;
⋯ 55 unchanged lines
105108 <SoundToggle prefs={prefs} onPrefs={onPrefs} />
106109 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
107110
111+ {/* Who is playing this hand. All three answers live in one place,
112+ and each is one tap away from either other: the computer has it,
113+ somebody at this screen has it, or it is on a phone. Nothing here
114+ deals anything, mid-hand included — the tiles simply change
115+ hands, which is what happens when somebody sits down. */}
116+ {who?.onSit && (
117+ <button
118+ className="btn ghost"
119+ onClick={() => {
120+ who.onSit!();
121+ onClose();
122+ }}
123+ >
124+ <Bi zh="我來打" en="I'll play it" sub={t('從電腦手上接過來', 'take it off the computer')} />
125+ </button>
126+ )}
127+ {who?.onBot && (
128+ <button
129+ className="btn ghost"
130+ onClick={() => {
131+ who.onBot!();
132+ onClose();
133+ }}
134+ >
135+ <Bi zh="電腦代打" en="Hand to computer" />
136+ </button>
137+ )}
138+
108139 {/* Taking this hand off the shared screen. The QR is the seat's own,
109140 so scanning it lands on this chair without anybody having to say
110141 which one they are in — and the tiles here go face down for good
111142 rather than under a card that lifts. */}
112- {seatUrl && (
113- <div className="menu-qr">
114- <SeatQr url={seatUrl} />
115- <span className="menu-qr-hint">
116- {lang !== 'en' && '掃碼把這副牌拿到手機上'}
117- {lang !== 'zh' && <em>scan to hold this hand on your phone</em>}
118- </span>
119- </div>
120- )}
143+ {who?.onPhone &&
144+ (seatUrl ? (
145+ <div className="menu-qr">
146+ <SeatQr url={seatUrl} />
147+ <span className="menu-qr-hint">
148+ {lang !== 'en' && '掃碼把這副牌拿到手機上'}
149+ {lang !== 'zh' && <em>scan to hold this hand on your phone</em>}
150+ </span>
151+ </div>
152+ ) : (
153+ // No room yet, and none was needed: a table of people and
154+ // computers never says a word to a server. The first person who
155+ // wants a phone in the game is the one who opens it.
156+ <button className="btn ghost" onClick={who.onPhone}>
157+ <Bi
158+ zh="用手機拿牌"
159+ en="Onto a phone"
160+ sub={t('開房間,出 QR', 'opens the room and puts up a QR')}
161+ />
162+ </button>
163+ ))}
121164
122165 {/* The moment anybody wants a name or the stakes changed is the
123166 moment they went looking for a gear. */}
⋯ 14 unchanged lines