anvilsign in

collin/mahjong

1import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2import { AutoPlay } from './game/autoplay';
3import type { TableCtl } from './game/ctl';
4import { Game, type SoundCue } from './game/engine';
5import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs';
6import { loadSave } from './game/save';
7import {
8 onAudioChange,
9 play,
10 setSoundEnabled,
11 unlock,
12 setSpatial,
13 setVoiceEnabled,
14 setVolume,
15 voiceLive,
16 type Cast,
17 type Pos,
18} from './game/sound';
19import { WIND_EN, WIND_NAMES } from './game/tiles';
20import type { SeatId } from './game/types';
21import { roomFromUrl, roomLink } from './net/protocol';
22import { NetSession } from './net/session';
23import { Bi, BiText } from './ui/Bi';
24import { Center } from './ui/Center';
25import { exposeGame, showingColliders } from './ui/colliders';
26import { useCompact } from './ui/compact';
27import { Adrift, Controller, SeatPick } from './ui/Controller';
28import { FullscreenToggle } from './ui/FullscreenToggle';
29import { enterFullscreen, useFullscreen } from './ui/fullscreen';
30import { Help } from './ui/Help';
31import { pick, setLang, useLang, useT } from './ui/lang';
32import { LanguageToggle } from './ui/LanguageToggle';
33import { TablePool } from './table/pool';
34import { Pool } from './ui/Pool';
35import { Seat, type SeatWho } from './ui/Seat';
36import { TableMenu } from './ui/TableMenu';
37import { Settings } from './ui/Settings';
38import './styles.css';
39
40const SEATS: SeatId[] = [0, 1, 2, 3];
41/**
42 * What 開始 deals: you at the bottom edge, the computer on the other three.
43 *
44 * It is a starting point rather than a mode. Anybody sitting down at this
45 * screen taps a computer's strip and takes that hand; anybody with a phone
46 * scans into it; and either of those can be given back to the computer again,
47 * mid-hand, without the table stopping. See `seatWho` below.
48 */
49const OPENING_SEATS = [false, true, true, true];
50
51// One join per page, whoever asks first — the invite, or the ?room= a shared
52// link carries. A promise rather than a flag, so a second caller waits on the
53// same join instead of starting another.
54let joining: Promise<NetSession> | null = null;
55const joinOnce = (engine: Game) => (joining ??= NetSession.join(engine));
56
57const subNothing = () => () => {};
58const zero = () => 0;
59
60/**
61 * Where a cue is heard, and by which device — everything the sound needs to
62 * know that only this screen can tell it. Handed to `sound.play` per cue; see
63 * `castFor` for the reasoning and `game/sound.ts` for what it does with it.
64 */
65interface Earshot {
66 /** Seat number → which edge of *this* screen it is sitting at. */
67 posOf: (seat: SeatId) => SeatId;
68 mySeat: SeatId | null;
69 /** Whether this device is the one the table is being played at. */
70 isTable: boolean;
71 /** Whether this device is a hand held beside a table that is making the
72 * noise for it — a phone in a room. */
73 handheld: boolean;
74 /** Seats whose own phone is saying their calls for them. */
75 voices: boolean[];
76}
77
78/**
79 * Which parts of a cue this device plays, and where from.
80 *
81 * Anything showing the whole table plays all of it, panned to the edge the
82 * seat is sitting at, so 碰 from 上家 comes from your left. That covers a
83 * screen on its own, and a screen with a room around it, and somebody playing
84 * from their own laptop three cities away.
85 *
86 * A phone in a room is the exception, and the reason is the room: the table is
87 * right there making the noise, so the phone makes none of it. It says its own
88 * seat's calls, because the words are that player's, and it chimes for a claim
89 * window somebody else opened, which is a nudge meant for the person holding
90 * it and nobody else. The table hears the phone claim the job — see `voices`
91 * in `net/protocol.ts` — and goes quiet for that seat, so the 碰 comes out of
92 * the hand of whoever called it.
93 */
94function castFor(cue: SoundCue, room: Earshot): Cast {
95 const seat = cue.seat;
96 if (room.handheld) {
97 // A phone still on the seat picker holds nothing and speaks for nobody.
98 if (seat === undefined || room.mySeat === null) return { voice: false, noise: false };
99 const mine = seat === room.mySeat;
100 // No panning: a phone is not a table, it is one player, and everything it
101 // says comes from where it is — in their hand.
102 return { pos: null, voice: mine, noise: mine ? false : cue.kind === 'claimWindow' };
103 }
104 const pos = seat === undefined ? null : (room.posOf(seat) as Pos);
105 const spoken = room.isTable && seat !== undefined && room.voices[seat] === true;
106 return { pos, voice: !spoken, noise: true };
107}
108
109export default function App() {
110 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
111 const t = useT();
112 const lang = useLang();
113 // Phone-sized: everything reads upright for the one person holding it.
114 const compact = useCompact();
115 const fullscreen = useFullscreen();
116 // Built once, from the preferences as they stood at startup; after that the
117 // settings screen edits the live game rather than replacing it.
118 const [game] = useState(() => new Game(prefs.names, prefs.rules));
119 useSyncExternalStore(game.subscribe, game.getSnapshot);
120 exposeGame(game);
121 // The tiles in the middle. Owned here rather than inside <Pool> because the
122 // seats need it too: a hand has to know whether it can be thrown from, and
123 // which seats those are changes as the wall is eaten away.
124 const [pool] = useState(() => new TablePool());
125 useSyncExternalStore(pool.subscribe, pool.getSnapshot);
126 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
127 // Whose gear is open — the same per-edge treatment as the rules panel, so
128 // what a player opens faces them and nobody else. See ui/TableMenu.
129 const [menuSeat, setMenuSeat] = useState<SeatId | null>(null);
130 const [settingsOpen, setSettingsOpen] = useState(false);
131 // Read once at startup; after that the live game is the source of truth.
132 const [saved] = useState(() => loadSave());
133
134 // ---- the room, if this page is in one ----------------------------------
135 const [session, setSession] = useState<NetSession | null>(null);
136 const [netErr, setNetErr] = useState<string | null>(null);
137 const [connecting, setConnecting] = useState(false);
138 useSyncExternalStore(session?.subscribe ?? subNothing, session?.getSnapshot ?? zero);
139 useSyncExternalStore(session?.table.subscribe ?? subNothing, session?.table.getSnapshot ?? zero);
140
141 /**
142 * Get this page into a room. `open` says this device is the one bringing the
143 * table: it publishes what it is already playing, and from then on the QRs
144 * work and a phone can take a hand off it.
145 *
146 * The publish happens before the session is handed to React on purpose — a
147 * render in between would catch a room that has joined but has no table in
148 * it yet, and flash the seat picker at the very device that owns the table.
149 */
150 const connect = async (open = false): Promise<NetSession | null> => {
151 setConnecting(true);
152 console.log('[mahjong] connecting…');
153 try {
154 const s = await joinOnce(game);
155 if (open) s.publishTable(prefs.names);
156 setSession(s);
157 setNetErr(null);
158 return s;
159 } catch (e) {
160 console.log('[mahjong] join failed:', e);
161 setNetErr(e instanceof Error ? e.message : String(e));
162 joining = null;
163 return null;
164 } finally {
165 setConnecting(false);
166 }
167 };
168
169 // Opened from a room link — ?room= in the address — so
170 // everyone on that link shares one table. Join it before anyone taps
171 // anything: presence is the first thing a shared room owes its visitors.
172 const auto = useRef(false);
173 useEffect(() => {
174 if (auto.current || !roomFromUrl()) return;
175 auto.current = true;
176 void connect();
177 // eslint-disable-next-line react-hooks/exhaustive-deps
178 }, []);
179
180 // The full-size tables hand phone throws to the pool, so a tile flicked off
181 // a phone comes flying in from that player's edge.
182 useEffect(() => {
183 if (!session) return;
184 session.onThrowFx = (seat, t) => {
185 pool.throwFromNet(seat, t);
186 pool.wake();
187 };
188 return () => {
189 session.onThrowFx = () => {};
190 };
191 }, [session, pool]);
192 // Where every cue is heard from, kept in a ref rather than in the closures
193 // below: the two subscriptions outlive any particular arrangement of the
194 // table, and re-subscribing on every rotation would drop cues mid-hand.
195 const earshot = useRef<Earshot>({
196 posOf: (s) => s,
197 mySeat: null,
198 isTable: true,
199 handheld: false,
200 voices: [false, false, false, false],
201 });
202
203 const mySeat = session?.mySeat ?? null;
204 /**
205 * Whether this device is the one the table is being played at.
206 *
207 * Without a room it always is — there is only the one screen. With one it is
208 * whichever device opened it, which is where every hand no phone has taken
209 * goes on being played. Everything else — a phone that scanned in, a friend
210 * on their own laptop — is holding a hand, not running a table.
211 */
212 const isTable = !session || session.isScreen;
213 /** A hand held on a phone, with the table it belongs to somewhere else. */
214 const handheld = !!session && compact && !isTable;
215
216 /**
217 * The table this page is playing at. It is the same engine either way: a
218 * room is a wrapper that goes on around a game in progress, not a second
219 * game, which is what lets four people join one at a time without the tiles
220 * being dealt again.
221 */
222 const ctl: TableCtl = session ? session.table : game;
223 const phase = ctl.state.phase;
224 /** Whether this device is the one actually running the game. */
225 const authority = !session || session.isHost;
226
227 // ---- who is playing which hand -----------------------------------------
228 const holdersOf = (seat: SeatId): string[] => session?.seats[seat].ids ?? [];
229 /** Whether this device answers for a seat: a hand it holds, or — at the
230 * table — a hand nobody has taken away and the computer is not playing. */
231 const playsHere = (seat: SeatId): boolean => {
232 const held = holdersOf(seat);
233 if (held.length > 0) return held.includes(session!.room.me.id);
234 return isTable && !ctl.state.bots[seat];
235 };
236 const mine = SEATS.filter(playsHere);
237
238 /**
239 * Turned so your own seat is the near edge — but only for a device holding
240 * one hand and no table. Four people round a screen are sitting where they
241 * are sitting, and turning the felt under them would move three of them.
242 */
243 const viewSeat: SeatId = !isTable && mine.length === 1 ? mine[0] : 0;
244 const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
245 // Everything the sound needs to know about where this screen is sitting, and
246 // who else in the room is going to be talking. See castFor, above.
247 earshot.current = {
248 posOf,
249 mySeat,
250 isTable,
251 handheld,
252 voices: session?.voiceSeats ?? [false, false, false, false],
253 };
254
255 // The one engine, so exactly one device runs the computer players, exactly
256 // one writes the save, and undo belongs to the table rather than to a hand.
257 useEffect(() => {
258 game.persist = authority;
259 }, [game, authority]);
260 useEffect(() => {
261 if (session) session.table.allowUndo = isTable;
262 }, [session, isTable]);
263
264 // Names and stakes live in the game state as well as in the preferences —
265 // saved games keep the settings they were played under.
266 const commitSettings = (p: Prefs) => {
267 setPrefs(p);
268 ctl.applySettings(p.names, p.rules);
269 setSettingsOpen(false);
270 };
271 // The panel auditions sound changes as you make them, so cancelling has to
272 // put the audio back the way it was.
273 const cancelSettings = () => {
274 setSoundEnabled(prefs.sound);
275 setVoiceEnabled(prefs.voice);
276 setVolume(prefs.volume);
277 setSpatial(prefs.spatial);
278 setSettingsOpen(false);
279 };
280 const settingsPanel = settingsOpen && (
281 <Settings
282 prefs={prefs}
283 onSave={commitSettings}
284 onClose={cancelSettings}
285 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
286 bots={ctl.state.bots}
287 />
288 );
289
290 // On <html> rather than passed around, so the panels that render outside the
291 // table — settings, the rules sheet, the lobby — can style themselves off it.
292 useEffect(() => {
293 document.documentElement.classList.toggle('compact', compact);
294 }, [compact]);
295
296 // Every <Bi> on the table reads the store rather than a prop, so this is the
297 // one place the saved preference has to reach it. It also sets <html lang>.
298 useEffect(() => setLang(prefs.lang), [prefs.lang]);
299
300 // Leaving fullscreen by the phone's own gesture is a decision too, and should
301 // not be undone the next time a hand starts. A reload never reaches here:
302 // there is no true-to-false to watch on a fresh mount, which is exactly what
303 // lets the preference survive one.
304 const wasFullscreen = useRef(false);
305 useEffect(() => {
306 // Same object back when it is already off, so the toggle's own click does
307 // not get written through a second time.
308 if (wasFullscreen.current && !fullscreen) {
309 setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p));
310 }
311 wasFullscreen.current = fullscreen;
312 }, [fullscreen]);
313
314 // The browser hands the screen back on every reload, and this game is built
315 // to be put down and picked up again. Starting a hand is a tap, and a tap is
316 // the only place the request is allowed — so that is where it goes back on.
317 const goFullscreen = () => {
318 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
319 };
320
321 /**
322 * 開始. One button, because there is one game.
323 *
324 * The tiles are dealt here and now, on this device, with nothing on the wire
325 * — you at the bottom edge and the computer on the other three. That is not
326 * a mode, it is a table with three chairs still empty, and it is the shape
327 * every other arrangement grows out of: somebody sits down at this screen
328 * and taps a computer's strip, somebody else scans a seat's QR onto their
329 * phone, a friend three cities away opens the invite link. Each of those
330 * swaps one hand, mid-game, and nothing is dealt again.
331 *
332 * Nothing here waits on a server, and a page that never reaches one still
333 * plays a full game.
334 */
335 const startGame = () => {
336 goFullscreen();
337 game.newGame(OPENING_SEATS, prefs.names);
338 void openRoom();
339 };
340 const resumeGame = () => {
341 goFullscreen();
342 game.restore(saved!.state);
343 void openRoom();
344 };
345
346 /**
347 * Open the room around this table, so that there is somewhere for a phone to
348 * be pointed. Done as the tiles are dealt, and in the background: the deal
349 * does not wait on it, and a table that never reaches a server plays a full
350 * game with no tile on the felt to scan and nothing else different.
351 *
352 * Opening a room is not the same as playing over one. All this puts on the
353 * wire is a seat map — the game itself stays on this device until somebody
354 * actually walks in, which is `publishGameIfOwed` in net/session.ts. An
355 * evening against three computers costs one handshake.
356 */
357 const openRoom = async (): Promise<NetSession | null> => {
358 // Already in a room — this page was opened on a link, or the table was
359 // dealt once already. Publishing is idempotent and turns away anybody who
360 // is not the host or whose room already has a table in it, so it is simply
361 // asked for rather than worked out.
362 if (session) {
363 session.publishTable(prefs.names);
364 return session;
365 }
366 return connect(true);
367 };
368 /** Take a hand onto a phone: put that seat's own QR up in its own gear,
369 * facing its own edge. */
370 const toPhone = async (seat: SeatId) => {
371 setMenuSeat(seat);
372 await openRoom();
373 };
374 /** A hand changing hands between a person at this screen and the computer.
375 * Networked or not, it is the same one call on the same one engine. */
376 const setSeatBot = (seat: SeatId, bot: boolean) => {
377 if (session) session.setBot(seat, bot);
378 else game.setBot(seat, bot);
379 };
380
381 // Preferences are written straight through, so a mute survives a refresh.
382 useEffect(() => {
383 savePrefs(prefs);
384 setSoundEnabled(prefs.sound);
385 setVoiceEnabled(prefs.voice);
386 setVolume(prefs.volume);
387 setSpatial(prefs.spatial);
388 }, [prefs]);
389
390 // A browser holds the audio hardware shut until the page has been touched,
391 // and a phone that arrived by QR may never touch anything that asks for it
392 // by name. So the first touch anywhere opens it — which on a phone is also
393 // what lets it tell the table it can do the talking.
394 useEffect(() => {
395 const wake = () => unlock();
396 window.addEventListener('pointerdown', wake, true);
397 window.addEventListener('keydown', wake, true);
398 return () => {
399 window.removeEventListener('pointerdown', wake, true);
400 window.removeEventListener('keydown', wake, true);
401 };
402 }, []);
403
404 // Tell the room whether this device is going to say anything, and say so
405 // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a
406 // phone's audio, the tab going to sleep.
407 //
408 // Only a phone claims the job. A phone is small enough to be in somebody's
409 // hand and a hand is in the room, so the table can safely go quiet for that
410 // seat and let the 碰 come out of it. A friend playing from their own laptop
411 // three cities away is not in the room and never takes the words away from
412 // anybody's table.
413 useEffect(() => {
414 if (!session) return;
415 const tell = () => session.reportVoice(voiceLive() && compact);
416 tell();
417 return onAudioChange(tell);
418 }, [session, compact, prefs.sound, prefs.voice]);
419 // One subscription, on whichever table this page is drawing. Off the engine
420 // when there is no room; off the room's own feed when there is, which on the
421 // host is the engine again and everywhere else is what the host sent.
422 useEffect(() => ctl.onSound((cue) => play(cue, castFor(cue, earshot.current))), [ctl]);
423 // Watches the table and only stirs when a seat the computer holds is the one
424 // being waited on. Exactly one device runs it: the one running the game, so
425 // three phones watching a table do not each play its computers' hands.
426 //
427 // The computer waits for a thrown tile to land — and for whoever it landed on
428 // to finish saying so — before it reaches for it. `showingColliders` holds it
429 // still as well: the throwing sandbox is no use if the table plays on around
430 // it.
431 useEffect(() => {
432 if (!authority) return;
433 return new AutoPlay(game, () => pool.busy || showingColliders()).start();
434 }, [authority, game, pool]);
435
436 // ---- routing ------------------------------------------------------------
437 // A device in somebody else's room holds a hand, not a table: a phone shows
438 // the hand and nothing else, and anything bigger shows the whole table
439 // turned round to that seat's edge. Either way it picks a chair first.
440 if (session && !isTable) {
441 if (session.open) {
442 if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
443 if (mine.length === 0) {
444 return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />;
445 }
446 // A room with no table in it, on a screen that could not show one anyway.
447 // The lobby underneath would offer to deal, which is not this device's to
448 // do and not what has gone wrong — see Adrift.
449 } else if (compact) {
450 return <Adrift session={session} />;
451 }
452 }
453
454 if (phase === 'lobby') {
455 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
456 return (
457 <div className="lobby">
458 {/* The title is already the bilingual pair: the name in Chinese, the
459 English underneath. Only the single-language modes change it. */}
460 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
461 <p className="sub">
462 {lang === 'zh'
463 ? '十六張 · 四人'
464 : lang === 'en'
465 ? // The name is already the heading above; here it would be twice.
466 '16 tiles · four players'
467 : 'Taiwanese Mahjong · 16 tiles · four players'}
468 </p>
469
470 {saved && (
471 <div className="resume">
472 <div className="resume-line">
473 {t(
474 `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`,
475 `${WIND_EN[round]} round, hand ${saved.state.handNumber}`,
476 ' · ',
477 )}
478 </div>
479 <div className="resume-scores">
480 {saved.state.players.map((p, i) => (
481 <span key={i}>
482 {p.name} <b>{p.score}</b>
483 </span>
484 ))}
485 </div>
486 <div className="resume-when">
487 {t('存於', 'saved')} {relativeTime(saved.savedAt, prefs.lang)}
488 </div>
489 </div>
490 )}
491
492 <div className="lobby-buttons">
493 {saved && authority && (
494 <button className="btn big-btn" onClick={resumeGame}>
495 <Bi zh="繼續對局" en="Resume" />
496 </button>
497 )}
498 {/* One button, because there is one game, and nothing under it: it
499 says 開始 and the table says the rest — three strips offering 坐下
500 and a QR in the middle of the felt. Explaining that here would be
501 explaining a thing nobody has seen yet. See startGame.
502
503 Hidden only in the one place it cannot work: standing in
504 somebody else's room, where the table is theirs to deal. */}
505 {authority && (
506 <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={startGame}>
507 <Bi zh="開始" en="Start" />
508 </button>
509 )}
510 {session && !session.isHost && !session.open && (
511 <p className="hint">
512 <BiText
513 zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主開局`}
514 en={`connected ${session.room.code} — waiting for the host to deal`}
515 />
516 </p>
517 )}
518 </div>
519
520 {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>}
521 {/* Where the connection stands, in so many words — but only when there
522 is something to say. A game that has not asked for a room yet is
523 not offline, it simply has not needed one. */}
524 {(connecting || session || roomFromUrl()) && (
525 <p className="hint">
526 {connecting
527 ? t('連線中', 'connecting…')
528 : session
529 ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t(
530 '人',
531 session.playerCount === 1 ? 'player' : 'players',
532 )}`
533 : t('未連線', 'not connected')}
534 </p>
535 )}
536
537 <div className="lobby-extras">
538 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
539 ⚙ {t('設定', 'Settings')}
540 </button>
541 {/* A setting, but out here where it can be found without first
542 reading the button that hides it. */}
543 <LanguageToggle prefs={prefs} onPrefs={setPrefs} compact={compact} />
544 {/* Only where the address bar is actually in the way. A desktop has
545 F11, and the lobby there does not need another button. */}
546 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
547 </div>
548 {settingsPanel}
549 </div>
550 );
551 }
552
553 /**
554 * What each seat is to this particular screen: whose buttons it shows, whose
555 * tiles it may turn over, and — because a hand can change hands mid-game —
556 * what it offers to do about it.
557 *
558 * The three answers are the same three everywhere: the computer is playing
559 * it, somebody at this screen is, or it has gone to a device of its own. Any
560 * of them turns into any other without the tiles being dealt again, which is
561 * the whole of what used to be three modes on a lobby.
562 */
563 const seatWho = (seat: SeatId): SeatWho | undefined => {
564 // A hand on somebody's own device is theirs; the table's controls for the
565 // other three are the table's business, not a visitor's.
566 if (!isTable) return undefined;
567 const slot = session?.seats[seat];
568 const held = slot?.ids.length ?? 0;
569 const bot = ctl.state.bots[seat];
570 return {
571 // More devices than one on a hand is a pair playing it together.
572 holder:
573 held > 0 ? `${slot!.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` : null,
574 // Its device went. The hand is being kept for a moment in case that was
575 // a pocket rather than a goodbye — see `vacate` in net/session.ts.
576 waiting: session?.isWaiting(seat) === true,
577 // The table has gone quiet for this seat on purpose: the phone on it is
578 // doing the calling.
579 speaks: session?.voiceSeats[seat] === true,
580 // Somebody has sat down in front of this edge and wants the hand the
581 // computer has been playing. One tap, mid-hand, tiles and all.
582 onSit: held === 0 && bot ? () => setSeatBot(seat, false) : undefined,
583 // And back again — nobody is going to play it, or nobody wants to.
584 onBot: held === 0 && !bot ? () => setSeatBot(seat, true) : undefined,
585 onPhone: held === 0 ? () => void toPhone(seat) : undefined,
586 };
587 };
588
589 /**
590 * Whether this screen is one several people are sitting round, in which case
591 * every hand played at it starts face down: your neighbour can see your
592 * tiles, and the answer used to be a piece of card laid over your strip.
593 *
594 * It is simply how many hands this device is playing. One is one person —
595 * playing the computer, or holding their own hand on their own device — and
596 * covering your own tiles from yourself is a tap in the way. Two or more is
597 * a screen with more than one person's hand on it, whatever else is true
598 * about the room.
599 */
600 const sharing = mine.length > 1;
601 // Where a hand goes when its player would rather hold it themselves. Only
602 // once there is a room for it to go to; before that the gear offers to open
603 // one instead.
604 const seatUrl = (seat: SeatId) =>
605 session ? roomLink(session.room.code, session.room.publicBase, seat) : undefined;
606 /**
607 * The table's own join link, which is the face of the tile lying in the
608 * middle of the felt — see ui/Pool. Undefined until there is a room, and
609 * undefined for good if there never is one, which is what a table with no
610 * server behind it looks like: the same game, one fewer tile.
611 *
612 * Only where this device is the table. A phone holding one hand has no
613 * business inviting anybody, and a screen turned round to somebody else's
614 * seat is not the table either.
615 */
616 /**
617 * Who is at this table besides the people in front of it, in one line: the
618 * devices somewhere else, and the hands the computer is playing.
619 *
620 * A table with phones on it has no other way of saying so out loud — a hand
621 * that went to somebody's phone shows a 📱 on its nameplate, but a phone
622 * still on the seat picker, or a friend watching from a laptop, is invisible.
623 * It is also the first thing to look at when a QR does not seem to be
624 * working: scan it and the number goes up.
625 */
626 const others = session ? Math.max(0, session.playerCount - 1) : 0;
627 const botCount = SEATS.filter((q) => ctl.state.bots[q]).length;
628 const tableLine = [
629 others > 0 && t(`${others} 台裝置連線中`, `${others} client${others === 1 ? '' : 's'} connected`, ' · '),
630 botCount > 0 && t(`${botCount} 家電腦`, `${botCount} bot${botCount === 1 ? '' : 's'}`, ' · '),
631 ]
632 .filter(Boolean)
633 .join(' · ');
634 const joinUrl =
635 session && isTable && session.invitable
636 ? roomLink(session.room.code, session.room.publicBase)
637 : undefined;
638
639 return (
640 <div className="table">
641 {/* How many other devices are in this room, in the corner. A table with
642 phones on it has no other way of saying so out loud — a hand that went
643 to somebody's phone shows a 📱 on its nameplate, but a phone still on
644 the seat picker, or a friend watching from a laptop, is invisible. It
645 is also the first thing to look at when a QR does not seem to be
646 working: scan it and the number goes up. */}
647 {tableLine && <div className="net-count">{tableLine}</div>}
648 {/* The wire has gone quiet. Said out loud on the table too, not only on
649 the lobby it is easy to have left behind: a hand that has stopped
650 answering should say why rather than looking like a game that has
651 stopped. The room reconnects on its own, and this clears when state
652 starts flowing again. */}
653 {session?.err && <div className="net-warn">{session.err}</div>}
654 {SEATS.map((s) => {
655 return (
656 <Seat
657 key={s}
658 game={ctl}
659 seat={s}
660 pos={posOf(s)}
661 local={playsHere(s)}
662 shared={sharing}
663 who={seatWho(s)}
664 compact={compact}
665 pool={compact ? undefined : pool}
666 onHelp={() => setHelpSeat(posOf(s))}
667 onMenu={compact ? undefined : () => setMenuSeat(s)}
668 />
669 );
670 })}
671 {/* The tiles in the middle, and among them the one with the room's QR
672 on its face. Not on a phone: the compact layout has no centre square
673 for them to be thrown into. */}
674 {!compact && (
675 <Pool game={ctl} pool={pool} viewSeat={viewSeat} joinUrl={joinUrl} />
676 )}
677 <Center
678 game={ctl}
679 prefs={prefs}
680 compact={compact}
681 viewSeat={viewSeat}
682 preferSeat={isTable ? null : mySeat}
683 onPrefs={setPrefs}
684 onSettings={() => setSettingsOpen(true)}
685 />
686 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
687 {menuSeat !== null && (
688 <TableMenu
689 game={ctl}
690 prefs={prefs}
691 pos={posOf(menuSeat)}
692 seatUrl={seatUrl(menuSeat)}
693 who={seatWho(menuSeat)}
694 compact={compact}
695 onPrefs={setPrefs}
696 onSettings={() => setSettingsOpen(true)}
697 onClose={() => setMenuSeat(null)}
698 />
699 )}
700 {settingsPanel}
701 </div>
702 );
703}
704
705function relativeTime(ts: number, lang: Lang): string {
706 // The number is the same in both, so it is said once and only the unit is
707 // picked — otherwise bilingual would read '5 分鐘前 · 5 min ago'.
708 const t = (zh: string, en: string) => pick(lang, zh, en, ' · ');
709 const mins = Math.round((Date.now() - ts) / 60000);
710 if (mins < 1) return t('剛剛', 'just now');
711 if (mins < 60) return `${mins} ${t('分鐘前', 'min ago')}`;
712 const hours = Math.round(mins / 60);
713 if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`;
714 const days = Math.round(hours / 24);
715 return `${days} ${t('天前', 'd ago')}`;
716}