anvilsign in

collin/mahjong · e58f5c3c

Ship it on antics: raw-URL QRs, a host that hosts while hidden, shared seats

Collin Richards · 2026-08-20 09:35 UTC · e58f5c3caf6449fc4778099b00597655f9844670 · parent 82287db9 · browse files

modifiedREADME.md+82 −1
1-# 台灣麻將 — four-player hotseat on one touchscreen
1+# 台灣麻將 — four-player mahjong, on one touchscreen or over the wire
22
33 Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
44 All four hands are on screen at once, each rotated to face its own edge — put a
⋯ 5 unchanged lines
1010 bottom seat and hands the other three to the computer — same rules, same
1111 table, same scoring. See [computer players](#computer-players).
1212
13+Friends elsewhere? **線上對戰 Online game** puts everyone at the same table
14+from their own devices, and **手機派對 Party mode** keeps the laptop as the
15+table while phones scan a QR to hold their own tiles — see
16+[playing online](#playing-online).
17+
1318 ```
1419 npm install
1520 npm run dev # then open the browser full-screen (F11)
⋯ 120 unchanged lines
136141 Stakes are locked once a game is under way — they'd otherwise rewrite chips
137142 already won.
138143
144+## Playing online
145+
146+Two networked modes, both built on [antics](https://antics.gg) — a room
147+service for web games with no backend of your own: one client (the antics
148+*host*) runs the engine, publishes the whole `GameState` as room state after
149+every move, and plays intents the other devices send as events. The engine was
150+already a pure JSON state machine, so nothing in `src/game/` knows the network
151+exists — the same trick `autoplay.ts` plays, stretched over a wire. All of it
152+lives in `src/net/`.
153+
154+- **線上對戰 Online game** — everyone on their own device. Whoever opens the
155+ room picks 線上對戰 and gets a seat lobby; friends who open the same link
156+ land in it, sit down, and the host deals. Empty seats go to the computer,
157+ which runs on the host client — that is what stands in for a server on the
158+ classic (free) tier. Each screen draws the same full table, turned so your
159+ own seat is the bottom edge; other people's tiles are face down. Joining
160+ mid-game takes over a vacant or computer seat, so a dropped friend can
161+ reload and carry on.
162+- **手機派對 Party mode** — the laptop stays the table, exactly like hotseat,
163+ and each seat's corner of the felt wears a QR. Scanning one loads a
164+ controller on the phone: just that hand, face up, with the claim buttons —
165+ and the throw. Flick a tile up off the top of the phone and it sails in from
166+ your edge of the common screen, at the speed and angle you let go of it
167+ (`NetThrow`, mapped into table coordinates by `TablePool.throwFromNet`). The
168+ seat's tiles go face down on the shared screen and its QR hides; hold the 📱
169+ tag on the nameplate to show it again for a player who lost theirs. A phone
170+ that leaves hands the seat back to the laptop. Seats never lock: a second
171+ phone scanning the same QR joins the same hand — two people playing one
172+ hand, whoever acts first acts.
173+
174+Design notes, in the order they bit:
175+
176+- **Seats are claims on a shared map**, `seats: {ids, name}[]` in room state,
177+ arbitrated by the host. Your own hand's arrangement never goes over the wire
178+ — the state carries a multiset and each device keeps its own order
179+ (`src/net/handOrder.ts`), because a round-trip inside a drag gesture is lag
180+ you can feel.
181+- **The host can change.** antics re-elects when the host leaves; every other
182+ client already mirrors the full state, so the new host starts its engine
183+ from what it was just watching and the game carries on.
184+- **A hidden host must keep hosting.** Browsers suspend requestAnimationFrame
185+ in background tabs, and the antics SDK flushes state writes off rAF — so a
186+ party screen somebody tabbed away from would silently stop answering.
187+ `src/net/clock.ts` feeds the SDK's `__anticsClock__` injection point from a
188+ Web Worker, whose timers are not throttled.
189+- **Fairness is social, not cryptographic.** The classic tier shares the whole
190+ state with every client, wall and hands included — a friend with devtools
191+ open can cheat. The antics sim tier would fix that, and costs money.
192+
193+### Hosting on antics
194+
195+```
196+npm run build && node scripts/antics-deploy.mjs # keyless — a fresh link each deploy
197+node scripts/antics-deploy.mjs --project <id> # stable /p/ link (needs `npx antics-cli login`)
198+```
199+
200+The deploy script packs the 57 voice clips into one `voice/pack.json` (an
201+antics bundle holds at most 50 files; `sound.ts` prefers the pack when it is
202+there). Keyless deploys are pinned to their content hash — share a new link
203+after every deploy — and keyless rooms hold 8 players and expire after 24
204+hours. Deploying under a project gives a share link that follows updates.
205+
206+The QR links point at the game's own URL (`/raw/<hash>/?room=…&seat=…`), not
207+the antics room page: the room page forwards only the parameters it knows
208+about to the game, and `seat` is not one of them — and a phone is better off
209+without the chrome anyway.
210+
139211 ## On a phone
140212
141213 The table assumes four people sitting around a screen lying flat, and about
⋯ 217 unchanged lines
359431 src/game/autoplay.ts when it does it, and how long it appears to think
360432 src/game/tai.ts 台 scoring
361433 src/game/engine.ts state machine: deal, turns, claim resolution, settlement
434+src/game/ctl.ts TableCtl — the surface the UI drives, local or networked
362435 src/game/wall.ts what is left of the square, and what still blocks a throw
436+src/net/session.ts the antics room: host duties, seats, intents, migration
437+src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay
438+src/net/protocol.ts what crosses the wire, in one place
439+src/net/handOrder.ts your own arrangement, kept on your own device
440+src/net/clock.ts worker-driven SDK clock, so a hidden host keeps hosting
441+src/ui/Controller.tsx the phone at the party table: your tiles and the throw
442+src/ui/OnlineLobby.tsx seats and the deal button, before an online game
443+scripts/antics-deploy.mjs dist/ → antics.gg, voice pack and all
363444 src/table/physics.ts the discard pool: rectangles with weight, pure and testable
364445 src/table/geometry.ts the table measured — colliders, launch points, throw gate
365446 src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
⋯ 53 unchanged lines
modifiedTODO.md+14 −0
⋯ 20 unchanged lines
2121 Flowers score, but some tables pay 花槓 out the moment it completes rather than
2222 at settlement, and some deal 補花 differently at the start. Both are `Rules`
2323 flags plus a payment hook.
24+## Online (new)
25+
26+- [ ] a stable share link: deploy under an antics project (`npx antics-cli
27+ login`, then `node scripts/antics-deploy.mjs --project <id>`) — keyless
28+ links pin one build and keyless rooms die after 24h
29+- [ ] undo online: only the party screen has it, and only while it is the
30+ antics 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
33+- [ ] cheat-resistance: room state carries the wall and every hand; the antics
34+ sim tier (paid) would keep secrets server-side
35+- [ ] voice/sfx settings on the phone controller (it inherits defaults, has no
36+ toggle of its own)
37+
2438 - [ ] make the scar bgger
2539 - [ ] fgure out the br nao
2640 - [ ] computer shouldn't be able to take the pung before the throw animation completes
⋯ 4 unchanged lines
modifiedscripts/antics-deploy.mjs+9 −0
⋯ 34 unchanged lines
3535
3636 async function collect() {
3737 const files = {};
38+ const voice = {};
3839 let bytes = 0;
3940 for await (const p of walk(DIST)) {
4041 const rel = relative(DIST, p);
4142 const ext = extname(rel);
4243 const buf = await readFile(p);
4344 bytes += buf.length;
45+ // A bundle may hold at most 50 files and the voice pack alone is 57, so
46+ // the clips ship as one JSON of base64 — sound.ts prefers it when present.
47+ if (rel.startsWith('voice/') && ext === '.mp3') {
48+ voice[rel.slice('voice/'.length, -'.mp3'.length)] = buf.toString('base64');
49+ continue;
50+ }
51+ if (rel === 'voice/manifest.json') continue;
4452 files[rel] = TEXT.has(ext)
4553 ? buf.toString('utf8')
4654 : `data:${MIME[ext] ?? 'application/octet-stream'};base64,${buf.toString('base64')}`;
4755 }
56+ if (Object.keys(voice).length) files['voice/pack.json'] = JSON.stringify(voice);
4857 return { files, bytes };
4958 }
5059
⋯ 42 unchanged lines
modifiedsrc/App.tsx+53 −8
⋯ 6 unchanged lines
77 import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
88 import { WIND_EN, WIND_NAMES } from './game/tiles';
99 import type { SeatId } from './game/types';
10-import { roomFromUrl, seatLink } from './net/protocol';
10+import { roomFromUrl, seatFromUrl, seatLink } from './net/protocol';
1111 import { NetSession } from './net/session';
1212 import { Center } from './ui/Center';
1313 import { useCompact } from './ui/compact';
⋯ 5 unchanged lines
1919 import { TablePool } from './table/pool';
2020 import { Pool } from './ui/Pool';
2121 import { Seat, type RemoteSeat } from './ui/Seat';
22+import { SeatQr } from './ui/SeatQr';
2223 import { Settings } from './ui/Settings';
2324 import './styles.css';
2425
⋯ 27 unchanged lines
5253 useSyncExternalStore(pool.subscribe, pool.getSnapshot);
5354 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
5455 const [settingsOpen, setSettingsOpen] = useState(false);
56+ // A claimed seat's QR, held back into view from its 📱 tag.
57+ const [qrPeek, setQrPeek] = useState<SeatId | null>(null);
5558 // Read once at startup; after that the live game is the source of truth.
5659 const [saved] = useState(() => loadSave());
5760
⋯ 6 unchanged lines
6467
6568 const connect = async (): Promise<NetSession | null> => {
6669 setConnecting(true);
70+ console.log('[mahjong] connecting…');
6771 try {
6872 const s = await joinOnce();
6973 setSession(s);
7074 setNetErr(null);
7175 return s;
7276 } catch (e) {
77+ console.log('[mahjong] join failed:', e);
7378 setNetErr(e instanceof Error ? e.message : String(e));
7479 joining = null;
7580 return null;
⋯ 35 unchanged lines
111116 useEffect(() => {
112117 if (!session || session.mode !== 'online' || !session.started) return;
113118 if (reclaimed.current || session.mySeat !== null) return;
114- if (session.seats.some((s) => s.id === null)) {
119+ if (session.seats.some((s) => s.ids.length === 0)) {
115120 reclaimed.current = true;
116121 session.claimSeat(null);
117122 }
⋯ 3 unchanged lines
121126 const mySeat = session?.mySeat ?? null;
122127 // The party table's common screen. Usually the device that started the game;
123128 // after a reload that flag is gone, so anything full-sized and unseated in a
124- // party room shows the table rather than a phone's controller.
125- const isScreen = session?.role === 'screen' || (netMode === 'party' && !compact && mySeat === null);
129+ // party room shows the table rather than a phone's controller — unless it
130+ // arrived through a seat's QR, which is a hand asking to be held whatever
131+ // the screen size.
132+ const isScreen =
133+ session?.role === 'screen' ||
134+ (netMode === 'party' && !compact && mySeat === null && seatFromUrl() === null);
126135 const netActive = !!session && (netMode === 'party' ? true : netMode === 'online' && session.started);
127136
128137 // Which engine this screen draws and drives.
⋯ 161 unchanged lines
290299 </div>
291300
292301 {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>}
302+ {/* Where the connection stands, in so many words. The room chrome is
303+ not always there to say it, and a button that cannot work yet
304+ should say why rather than doing nothing. */}
305+ <p className="hint">
306+ {connecting
307+ ? '連線中 connecting…'
308+ : session
309+ ? `已連線 connected · ${session.room.code} · ${session.playerCount} 人 · ${
310+ session.isHost ? '你是房主 you host' : '房主在別台 host elsewhere'
311+ }`
312+ : roomFromUrl()
313+ ? '未連線 not connected'
314+ : '本機 offline · 線上模式會自動連線 online modes connect on tap'}
315+ </p>
293316
294317 <div className="lobby-extras">
295318 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
⋯ 14 unchanged lines
310333 const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => {
311334 if (!netActive) return { local: true };
312335 const slot = session!.seats[seat];
336+ const held = slot.ids.length;
313337 if (netMode === 'party') {
314338 return {
315339 // A seat nobody has scanned is played right here, hotseat-style.
316- local: slot.id === null,
340+ local: held === 0,
317341 remote: {
318- qrUrl: seatLink(session!.room.link, seat),
319- holder: slot.id !== null ? slot.name || '手機 phone' : null,
342+ // More phones than one on a hand is a pair playing it together.
343+ holder: held > 0 ? `${slot.name || '手機 phone'}${held > 1 ? ` ×${held}` : ''}` : null,
344+ onPeek: (show: boolean) => setQrPeek(show ? seat : null),
320345 },
321346 };
322347 }
⋯ 1 unchanged line
324349 local: seat === mySeat,
325350 // A deserted human seat: the host's screen offers it to the computer.
326351 remote:
327- slot.id === null && !ctl.state.bots[seat] && session!.isHost
352+ held === 0 && !ctl.state.bots[seat] && session!.isHost
328353 ? { holder: null, onHandToBot: () => session!.handToBot(seat) }
329354 : undefined,
330355 };
⋯ 20 unchanged lines
351376 {/* The tiles in the middle. Not on a phone: the compact layout has no
352377 centre square for them to be thrown into. */}
353378 {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />}
379+ {/* Party: each free seat's QR, in the corner of the felt to that seat's
380+ right — the corners are the one part of the table nothing else wants,
381+ and the strips clip their own contents. Scanning one takes that hand
382+ to the phone; the QR comes back if the phone goes, or while its 📱
383+ tag is held down. */}
384+ {netMode === 'party' &&
385+ !compact &&
386+ SEATS.map((seat) => {
387+ const slot = session!.seats[seat];
388+ if (slot.ids.length > 0 && qrPeek !== seat) return null;
389+ return (
390+ <div key={seat} className={`qr-dock corner-${posOf(seat)}`}>
391+ <SeatQr url={seatLink(session!.room.code, seat)} />
392+ <span className="qr-hint">
393+ 掃碼用手機拿這手牌
394+ <em>scan to play this hand from your phone</em>
395+ </span>
396+ </div>
397+ );
398+ })}
354399 <Center
355400 game={ctl}
356401 prefs={prefs}
⋯ 21 unchanged lines
modifiedsrc/game/sound.ts+18 −0
⋯ 82 unchanged lines
8383 * clicked. So the bytes come down straight away and are decoded later, on the
8484 * first gesture — by which time the whole pack (~330 kB) is already in hand.
8585 */
86+/** base64 → the bytes decodeAudioData wants. */
87+function fromB64(b64: string): ArrayBuffer {
88+ const bin = atob(b64);
89+ const out = new Uint8Array(bin.length);
90+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
91+ return out.buffer;
92+}
93+
8694 function fetchVoicePack(): Promise<void> {
8795 if (fetching) return fetching;
8896 fetching = (async () => {
97+ // Deployed, the whole pack rides in one file — an antics bundle may hold
98+ // at most 50 files, and the voice alone is more than that loose. In dev
99+ // the loose mp3s are still there and the fallback below takes them.
100+ const packed: Record<string, string> | null = await fetch(`${VOICE_DIR}pack.json`)
101+ .then((r) => (r.ok ? r.json() : null))
102+ .catch(() => null);
103+ if (packed) {
104+ for (const [name, b64] of Object.entries(packed)) encoded.set(name, fromB64(b64));
105+ return;
106+ }
89107 const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json());
90108 await Promise.all(
91109 names.map(async (name) => {
⋯ 296 unchanged lines
addedsrc/net/clock.ts+58 −0
1+/**
2+ * Keep the room alive while this tab is hidden.
3+ *
4+ * The antics SDK runs its loops — the state flush above all — off
5+ * requestAnimationFrame, which a browser suspends the moment a tab stops being
6+ * visible. For most games that is right: a hidden player is not playing. The
7+ * party table is different — the common screen is the room's host, and a host
8+ * that stops flushing takes the whole room with it: a phone's seat claim is
9+ * processed but never announced, so the phone sits on "pick a seat" forever
10+ * while the answer waits in a queue for the laptop to be looked at.
11+ *
12+ * The SDK provides for exactly this: anything on `globalThis.__anticsClock__`
13+ * replaces its own timers, resolved when each loop starts. So the ticks come
14+ * from a Web Worker instead — worker timers are not throttled in background
15+ * tabs — and the host keeps hosting whether or not its tab is front.
16+ *
17+ * Installed before joinRoom, once. The game's own render loops are untouched:
18+ * they use the real requestAnimationFrame and are happy to sleep with the tab.
19+ */
20+export function installBackgroundClock() {
21+ const g = globalThis as unknown as Record<string, unknown>;
22+ if (g.__anticsClock__) return;
23+
24+ let worker: Worker | null = null;
25+ try {
26+ const src = URL.createObjectURL(
27+ new Blob(['setInterval(() => postMessage(0), 66);'], { type: 'text/javascript' }),
28+ );
29+ worker = new Worker(src);
30+ URL.revokeObjectURL(src);
31+ } catch {
32+ // A sandbox that refuses workers keeps the SDK's own timers — no worse
33+ // than before.
34+ return;
35+ }
36+
37+ const callbacks = new Map<number, (t: number) => void>();
38+ let nextId = 1;
39+ worker.onmessage = () => {
40+ if (callbacks.size === 0) return;
41+ const now = performance.now();
42+ const due = [...callbacks.values()];
43+ callbacks.clear();
44+ for (const cb of due) cb(now);
45+ };
46+
47+ g.__anticsClock__ = {
48+ now: () => Date.now(),
49+ requestAnimationFrame(cb: (t: number) => void): number {
50+ const id = nextId++;
51+ callbacks.set(id, cb);
52+ return id;
53+ },
54+ cancelAnimationFrame(id: number) {
55+ callbacks.delete(id);
56+ },
57+ };
58+}
modifiedsrc/net/protocol.ts+27 −11
⋯ 18 unchanged lines
1919 /** The two ways a room can be playing. */
2020 export type NetMode = 'online' | 'party';
2121
22-/** Who holds a seat. The name outlives the connection — see the antics docs on
23- * leavers: record it where it arrives, the match outlives the connection. */
22+/**
23+ * Who holds a seat. A list, not a single id: a seat is a hand, and nothing
24+ * stops two people huddling over one hand — a pair playing together from two
25+ * phones simply both hold it, and whoever acts first acts. The name outlives
26+ * every connection — see the antics docs on leavers: record it where it
27+ * arrives, the match outlives the connection.
28+ */
2429 export interface SeatSlot {
25- /** antics player id, or null while nobody holds it. */
26- id: string | null;
30+ /** antics player ids of everyone holding this hand. */
31+ ids: string[];
2732 name: string;
2833 }
2934
⋯ 40 unchanged lines
7075 export const EV_FX = 'fx';
7176
7277 export const EMPTY_SEATS: SeatSlot[] = [
73- { id: null, name: '' },
74- { id: null, name: '' },
75- { id: null, name: '' },
76- { id: null, name: '' },
78+ { ids: [], name: '' },
79+ { ids: [], name: '' },
80+ { ids: [], name: '' },
81+ { ids: [], name: '' },
7782 ];
7883
7984 /** The seat in the QR's link, if the hosting page passed it through to us. */
⋯ 17 unchanged lines
97102 }
98103 }
99104
100-/** The room's invite link with the seat stitched in, for that seat's QR. */
101-export const seatLink = (link: string, seat: SeatId) =>
102- `${link}${link.includes('?') ? '&' : '?'}seat=${seat}`;
105+/**
106+ * The link a seat's QR carries: this very page, with the room and the seat in
107+ * the query. Not the room's invite link — the antics room page forwards only
108+ * the params it knows about to the game, and `seat` is not one of them. The
109+ * page's own URL (the raw bundle on antics, the dev server at home) serves the
110+ * game directly and keeps every param, which on a phone also means the whole
111+ * screen belongs to the hand.
112+ */
113+export function seatLink(code: string, seat: SeatId): string {
114+ const u = new URL(window.location.href);
115+ u.search = `?room=${code}&seat=${seat}`;
116+ u.hash = '';
117+ return u.toString();
118+}
modifiedsrc/net/session.ts+75 −22
11 import { joinRoom, type Room } from 'antics-sdk';
22 import { AutoPlay } from '../game/autoplay';
3+import { installBackgroundClock } from './clock';
34 import type { NetThrow } from '../game/ctl';
45 import type { SoundEvent } from '../game/engine';
56 import type { Tile } from '../game/tiles';
⋯ 94 unchanged lines
100101 // Mirror whatever the room already holds — a late joiner walks in on the
101102 // game as it stands, not on an empty table.
102103 this.table.adopt(this.sharedGame());
104+
105+ if (import.meta.env.DEV) {
106+ (window as unknown as Record<string, unknown>).__mahjongSession = this;
107+ }
108+ // One line to identify a session from any console, the sandboxed iframe's
109+ // included — where nothing else of ours can be reached from outside.
110+ console.log(`[mahjong] joined ${room.code} as ${room.me.name}, host: ${room.isHost}`);
111+ room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`));
103112 }
104113
105114 /** Join the room in the page URL, or create a fresh one. */
106115 static async join(): Promise<NetSession> {
107- const room = await joinRoom({});
116+ // Before the SDK starts any loop: this device may end up hosting the
117+ // room, and a host must keep answering with its tab in the background.
118+ installBackgroundClock();
119+ // Deployed on antics the SDK's same-origin detection is right; served from
120+ // anywhere else — the dev server, most of all — it would knock on a host
121+ // that has no room server behind it and hang, so name the real one.
122+ const onAntics = /(^|\.)antics\.gg$/.test(window.location.hostname);
123+ const room = await joinRoom(onAntics ? {} : { server: 'https://antics.gg' });
108124 return new NetSession(room);
109125 }
110126
⋯ 30 unchanged lines
141157 const s = this.room.state.seats;
142158 return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS;
143159 }
160+ /** What a seat is called when nobody from the room holds it — the name the
161+ * party table dealt it with, or nothing at all online. */
162+ private homeName(seat: SeatId): string {
163+ const h = this.room.state.homes;
164+ return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : '';
165+ }
144166 private sharedGame(): unknown {
145167 return this.room.state.game ?? null;
146168 }
147169 get mySeat(): SeatId | null {
148170 const seats = this.seats;
149- for (const i of SEATS) if (seats[i].id === this.room.me.id) return i;
171+ for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i;
150172 return null;
151173 }
152174 /** Whether a hand has actually been dealt in this room. */
⋯ 14 unchanged lines
167189 */
168190 openOnlineLobby() {
169191 if (!this.isHost) return;
170- this.room.setState({ mode: 'online', seats: structuredClone(EMPTY_SEATS) as never });
192+ this.room.setState({
193+ mode: 'online',
194+ seats: structuredClone(EMPTY_SEATS) as never,
195+ homes: ['', '', '', ''] as never,
196+ });
171197 this.claimSeat(0);
172198 }
173199
⋯ 1 unchanged line
175201 startOnline(rules: Rules) {
176202 if (!this.isHost) return;
177203 const seats = this.seats;
178- const bots = seats.map((s) => s.id === null);
204+ const bots = seats.map((s) => s.ids.length === 0);
179205 if (bots.every(Boolean)) return; // nobody seated at all
180206 const names = seats.map((s, i) => s.name || `玩家 ${i + 1}`);
181207 const eng = this.table.engine;
⋯ 8 unchanged lines
190216 * leaves.
191217 */
192218 startParty(names: string[], rules: Rules) {
219+ console.log(`[mahjong] startParty (host: ${this.isHost})`);
193220 if (!this.isHost) return;
194221 this.role = 'screen';
195- const seats = EMPTY_SEATS.map((_, i) => ({ id: null, name: names[i] ?? '' }));
196- this.room.setState({ mode: 'party', seats: seats as never });
222+ const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' }));
223+ this.room.setState({
224+ mode: 'party',
225+ seats: seats as never,
226+ homes: seats.map((s) => s.name) as never,
227+ });
197228 const eng = this.table.engine;
198229 eng.applySettings(names, rules);
199230 eng.newGame([false, false, false, false], names);
⋯ 8 unchanged lines
208239 /** Hand a deserted seat to the computer (host's own button). */
209240 handToBot(seat: SeatId) {
210241 if (!this.isHost) return;
211- if (this.seats[seat].id !== null) return;
242+ if (this.seats[seat].ids.length > 0) return;
212243 this.table.engine.setBot(seat, true);
213244 }
214245
⋯ 3 unchanged lines
218249 }
219250
220251 // ---- host duties -------------------------------------------------------
252+ /**
253+ * A claim never fails. A seat is a hand, not a chair: two phones may hold
254+ * the same one — a pair playing together — and whichever acts first acts.
255+ * Claiming a different seat lets go of the one you had.
256+ */
221257 private handleSeat(msg: SeatMsg, from: string) {
222258 if (!this.isHost) return;
223259 const name = this.room.player(from)?.name ?? '';
224260 const seats = structuredClone(this.seats);
225261 let seat = msg.seat;
226262 if (seat === null) {
227- seat = SEATS.find((i) => seats[i].id === null) ?? null;
263+ seat = SEATS.find((i) => seats[i].ids.length === 0) ?? null;
228264 if (seat === null) return;
229265 }
230- if (seats[seat].id !== null && seats[seat].id !== from) return; // taken
231- // One seat per player: claiming a new one lets go of the old.
232- for (const i of SEATS) if (seats[i].id === from) seats[i] = { id: null, name: seats[i].name };
233- seats[seat] = { id: from, name: name || seats[seat].name };
234- this.room.setState({ seats: seats as never });
266+ for (const i of SEATS) seats[i].ids = seats[i].ids.filter((id) => id !== from);
267+ seats[seat].ids.push(from);
268+ // The first holder names the hand; a second pair of hands joins theirs.
269+ if (seats[seat].ids.length === 1 && name) seats[seat].name = name;
270+ this.settleSeats(seats);
235271
236272 // Mid-game, the seat comes back from the computer (or the common screen)
237273 // to the person who claimed it.
238274 if (this.sharedGame() !== null) {
239275 this.table.engine.setBot(seat, false);
240- if (name) this.table.engine.renameSeat(seat, name);
276+ if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name);
241277 }
242278 this.bump();
243279 }
⋯ 2 unchanged lines
246282 const seats = structuredClone(this.seats);
247283 let changed = false;
248284 for (const i of SEATS) {
249- if (seats[i].id === id) {
250- seats[i] = { id: null, name: seats[i].name };
251- changed = true;
252- }
285+ const kept = seats[i].ids.filter((x) => x !== id);
286+ if (kept.length !== seats[i].ids.length) changed = true;
287+ seats[i].ids = kept;
253288 }
254- if (changed) this.room.setState({ seats: seats as never });
289+ if (changed) this.settleSeats(seats);
255290 // The seat stays human: at a party table the common screen simply plays it
256291 // again, and online the host is offered the 電腦代打 button instead of the
257292 // table deciding on its own that a dropped friend is gone for good.
258293 }
259294
295+ /** Publish the seat map, giving emptied hands their own names back. */
296+ private settleSeats(seats: SeatSlot[]) {
297+ const party = this.mode === 'party';
298+ for (const i of SEATS) {
299+ if (seats[i].ids.length > 0) continue;
300+ if (party) {
301+ // Back to the common screen under the table's own name; online the
302+ // engine keeps the leaver's name — a deserted hand mid-game is still
303+ // theirs to talk about, and to come back to.
304+ seats[i].name = this.homeName(i);
305+ if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name);
306+ } else {
307+ seats[i].name = '';
308+ }
309+ }
310+ this.room.setState({ seats: seats as never });
311+ }
312+
260313 private handleAct(msg: ActMsg, from: string) {
261314 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
262315 const seat = msg.seat;
263- // Their own seat — or one nobody holds, which is how the party screen
316+ // A hand they hold — or one nobody holds, which is how the party screen
264317 // plays its unclaimed seats when it is not the one running the engine
265318 // (after a reload, say, when a phone has inherited the host's chair).
266- const holder = this.seats[seat]?.id;
267- if (holder !== from && holder !== null) return;
319+ const holders = this.seats[seat]?.ids ?? [];
320+ if (holders.length > 0 && !holders.includes(from)) return;
268321 const eng = this.table.engine;
269322 const act = msg.act;
270323 switch (act.kind) {
⋯ 61 unchanged lines
modifiedsrc/styles.css+28 −5
⋯ 1996 unchanged lines
19971997 It must not cover the hand: an unclaimed seat is still played right here. */
19981998 .qr-dock {
19991999 position: absolute;
2000- right: 6px;
2001- bottom: 34px;
20022000 z-index: 3;
20032001 display: flex;
20042002 flex-direction: column;
⋯ 3 unchanged lines
20082006 border-radius: 10px;
20092007 background: rgba(10, 49, 38, 0.92);
20102008 border: 1px solid rgba(217, 180, 92, 0.45);
2009+ /* Purely something to look at: taps fall through to the table under it. */
2010+ pointer-events: none;
2011+}
2012+/* One corner per seat, to that player's right, turned to face them the way
2013+ their strip is. The corners are the felt the four strips leave over. */
2014+.qr-dock.corner-0 {
2015+ right: 10px;
2016+ bottom: 10px;
2017+}
2018+.qr-dock.corner-1 {
2019+ /* Turned a quarter, the tall dock swings wider than its box — stand it
2020+ further off the edge so nothing pokes off screen. */
2021+ right: 42px;
2022+ top: 10px;
2023+ transform: rotate(-90deg);
20112024 }
2025+.qr-dock.corner-2 {
2026+ left: 10px;
2027+ top: 10px;
2028+ transform: rotate(180deg);
2029+}
2030+.qr-dock.corner-3 {
2031+ left: 42px;
2032+ bottom: 10px;
2033+ transform: rotate(90deg);
2034+}
20122035 .seat-qr {
20132036 width: clamp(72px, calc(var(--depth) * 0.44), 132px);
20142037 aspect-ratio: 1;
⋯ 33 unchanged lines
20482071 screen, with the common screen doing everything else. Tiles get the room —
20492072 they are the only thing this screen is for. */
20502073 .ctl {
2051- --tile-w: clamp(40px, 5.2vw, 76px);
2074+ /* The whole hand in one row: 17 tiles and their gaps must fit the width. */
2075+ --tile-w: clamp(30px, calc((100vw - 56px) / 17.6), 76px);
20522076 position: fixed;
20532077 inset: 0;
20542078 display: flex;
⋯ 36 unchanged lines
20912115 color: var(--gold);
20922116 }
20932117 .ctl-status {
2094- margin-left: auto;
20952118 opacity: 0.75;
20962119 }
20972120 .ctl-status em {
⋯ 10 unchanged lines
21082131 display: inline-flex;
21092132 align-items: center;
21102133 gap: 4px;
2134+ margin-left: auto;
21112135 }
21122136 .ctl-top .chips {
21132137 font-weight: 600;
⋯ 17 unchanged lines
21312155 display: flex;
21322156 justify-content: center;
21332157 padding: 0 8px calc(10px + env(safe-area-inset-bottom, 0px));
2134- overflow-x: auto;
21352158 }
21362159 .ctl-hand .hand {
21372160 flex-wrap: nowrap;
⋯ 76 unchanged lines
modifiedsrc/ui/Controller.tsx+18 −17
1-import { useEffect, useRef, useState } from 'react';
1+import { useEffect, useState } from 'react';
22 import { pendingOptions } from '../game/ctl';
33 import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles';
44 import type { SeatId } from '../game/types';
⋯ 18 unchanged lines
2323
2424 // The QR carries which seat it was stuck to; a phone that arrived some other
2525 // way — or lost the parameter on the way in — picks one by hand below.
26- const asked = useRef(false);
26+ const [asked, setAsked] = useState(false);
2727 useEffect(() => {
28- if (asked.current || seat !== null) return;
28+ if (asked || seat !== null) return;
2929 const want = seatFromUrl();
30- if (want !== null && session.seats[want].id === null) {
31- asked.current = true;
30+ if (want !== null) {
31+ setAsked(true);
3232 session.claimSeat(want);
3333 }
34- }, [session, seat]);
34+ }, [session, seat, asked]);
3535
36- if (seat === null) return <SeatPick session={session} />;
36+ if (seat === null) return <SeatPick session={session} joining={asked} />;
3737 return <SeatStrip session={session} seat={seat} />;
3838 }
3939
40-function SeatPick({ session }: { session: NetSession }) {
40+function SeatPick({ session, joining }: { session: NetSession; joining?: boolean }) {
4141 const seats = session.seats;
4242 const s = session.table.state;
4343 return (
4444 <div className="ctl ctl-pick">
4545 <h1>台灣麻將</h1>
46- <p className="sub">選一個座位 · pick your seat</p>
46+ <p className="sub">
47+ {joining ? '入座中… taking your seat — 或先選別的 or pick another' : '選一個座位 · pick your seat'}
48+ </p>
4749 <div className="pick-grid">
4850 {SEATS.map((i) => {
49- const taken = seats[i].id !== null;
51+ const held = seats[i].ids.length;
5052 const wind = (i - s.dealer + 4) % 4;
5153 return (
52- <button
53- key={i}
54- className="btn big-btn"
55- disabled={taken}
56- onClick={() => session.claimSeat(i)}
57- >
54+ <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}>
5855 {WIND_NAMES[wind]} {WIND_EN[wind]}
59- <em>{taken ? `已有人 · ${seats[i].name}` : seats[i].name || '空位 open'}</em>
56+ <em>
57+ {held > 0
58+ ? `${seats[i].name} — 一起打 join this hand`
59+ : seats[i].name || '空位 open'}
60+ </em>
6061 </button>
6162 );
6263 })}
⋯ 211 unchanged lines
modifiedsrc/ui/OnlineLobby.tsx+9 −9
⋯ 16 unchanged lines
1717 const me = session.room.me.id;
1818
1919 // Walking in unseated: take the first free chair rather than making everyone
20- // pick one. Moving is one tap on another seat.
20+ // pick one. Moving is one tap on another seat — including one somebody
21+ // already holds, which is how two people share a hand.
2122 const claimed = useRef(false);
2223 useEffect(() => {
2324 if (claimed.current || session.mySeat !== null) return;
24- if (seats.some((s) => s.id === null)) {
25+ if (seats.some((s) => s.ids.length === 0)) {
2526 claimed.current = true;
2627 session.claimSeat(null);
2728 }
2829 }, [session, seats]);
2930
30- const seated = seats.filter((s) => s.id !== null).length;
31+ const seated = seats.filter((s) => s.ids.length > 0).length;
3132
3233 return (
3334 <div className="lobby">
⋯ 8 unchanged lines
4243 <div className="pick-grid">
4344 {SEATS.map((i) => {
4445 const slot = seats[i];
45- const mine = slot.id === me;
46- const taken = slot.id !== null && !mine;
46+ const mine = slot.ids.includes(me);
47+ const held = slot.ids.length;
4748 return (
4849 <button
4950 key={i}
5051 className={`btn big-btn ${mine ? '' : 'ghost'}`}
51- disabled={taken}
5252 onClick={() => !mine && session.claimSeat(i)}
5353 >
5454 {WIND_NAMES[i]} {WIND_EN[i]}
5555 <em>
56- {slot.id === null
56+ {held === 0
5757 ? '空位,開局時由電腦代打 · open — a bot if nobody sits'
5858 : mine
59- ? `${slot.name || '你'} · you`
60- : slot.name}
59+ ? `${slot.name || '你'} · you${held > 1 ? ` +${held - 1}` : ''}`
60+ : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · 可同坐 join them`}
6161 </em>
6262 </button>
6363 );
⋯ 20 unchanged lines
modifiedsrc/ui/Seat.tsx+8 −22
⋯ 4 unchanged lines
55 import type { TablePool } from '../table/pool';
66 import { Bi, CLAIM_LABEL } from './Bi';
77 import { Hand } from './Hand';
8-import { SeatQr } from './SeatQr';
98 import { SEAT_ROT, SEAT_SLOT } from './rotation';
109 import { TileView } from './TileView';
1110
⋯ 2 unchanged lines
1413 * player. What of it shows on this screen is decided here.
1514 */
1615 export interface RemoteSeat {
17- /** Party screen: the link that claims this seat, drawn as a QR while free. */
18- qrUrl?: string;
1916 /** Who holds it from their own device, or null while nobody does. */
2017 holder?: string | null;
18+ /** Party screen: holding the 📱 tag re-shows this seat's QR on the table,
19+ * for the player who lost theirs. The QR itself lives in the table's
20+ * corner — see App — because the strip clips its own contents. */
21+ onPeek?: (show: boolean) => void;
2122 /** Online host: the seat's player is gone; offer it to the computer. */
2223 onHandToBot?: () => void;
2324 }
⋯ 25 unchanged lines
4950 const s = game.state;
5051 const p = s.players[seat];
5152 const [sel, setSel] = useState<number | null>(null);
52- // Held down to re-show a claimed seat's QR — for the player who lost theirs.
53- const [peek, setPeek] = useState(false);
5453 const bot = s.bots[seat];
5554 // Somebody this device answers for: a person here, not the computer, not a
5655 // hand that lives on another screen.
⋯ 109 unchanged lines
166165 </div>
167166 )}
168167
169- {/* Party screen: a free seat wears its QR; scanning it takes the hand
170- to a phone. A claimed seat hides it, but its player can ask for it
171- back — the 手機 tag below holds it down to reveal. */}
172- {remote?.qrUrl && (remote.holder == null || peek) && (
173- <div className="qr-dock">
174- <SeatQr url={remote.qrUrl} />
175- <span className="qr-hint">
176- 掃碼用手機拿牌
177- <em>scan to play from your phone</em>
178- </span>
179- </div>
180- )}
181-
182168 {/* Only on a phone. At full size a discard is thrown into the middle
183169 and stays there for the hand, so the row would be the same tiles
184170 listed twice. */}
⋯ 119 unchanged lines
304290 <span
305291 className="tag phone"
306292 title="這手牌在手機上 · played from a phone. 按住看 QR · hold to show the QR again"
307- onPointerDown={() => setPeek(true)}
308- onPointerUp={() => setPeek(false)}
309- onPointerCancel={() => setPeek(false)}
310- onPointerLeave={() => setPeek(false)}
293+ onPointerDown={() => remote.onPeek?.(true)}
294+ onPointerUp={() => remote.onPeek?.(false)}
295+ onPointerCancel={() => remote.onPeek?.(false)}
296+ onPointerLeave={() => remote.onPeek?.(false)}
311297 >
312298 📱 {remote.holder}
313299 </span>
⋯ 30 unchanged lines