anvilsign in

collin/mahjong · 0d844b66

A link a phone on the wifi can open, in 25 modules

Collin Richards · 2026-08-25 10:43 UTC · 0d844b664c9743d56a156daefdfe49f0e86fb760 · parent e8590b15 · browse files

modifiedREADME.md+61 −22
⋯ 15 unchanged lines
1616
1717 - somebody sits down at this screen and **taps a hand the computer has been
1818 playing** — the tiles turn over and are theirs;
19-- somebody **scans the tile lying in the middle of the felt** — it says SCAN
20- and JOIN, and it gets knocked about by the discards like everything else
21- there — or a particular seat's own QR from its gear, and takes that hand onto
22- their phone;
19+- somebody **scans the tile lying in the middle of the felt** — it gets knocked
20+ about by the discards like everything else there — or a particular seat's own
21+ QR from its gear, and takes that hand onto their phone;
2322 - somebody **opens the invite link** from three cities away and takes one from
2423 their own laptop, with the table turned so their seat is the near edge.
2524
⋯ 230 unchanged lines
256255 around a live `Game`, not a second one, so a room arriving mid-hand changes
257256 nothing on screen except that the QR tile appears.
258257
259-**The table's QR is a tile.** It lies in the middle of the felt with SCAN above
260-the code and JOIN below it, white face, dark-green ink, in the two bands a suit
261-tile spends on its number and its suit — and it is a tile all the way down. It
258+**The table's QR is a tile.** It lies in the middle of the felt at the size
259+every other tile is, white face and dark-green ink, and it is a tile all the
260+way down. It
262261 is a body in the pool like any other (`TablePool.joinTile`): a discard thrown
263262 at it knocks it aside and turns it, it can be picked up and put somewhere less
264263 in the way or flicked off across the cloth, the same one light over the table
⋯ 63 unchanged lines
328327 way in, and the ones somebody already holds, which is how two people play one
329328 hand off two devices. A seat's own QR names its chair and skips the picker.
330329 - **The device the table was opened on** goes on showing the table: the felt,
331- the middle, the wall, the SCAN/JOIN tile lying among the discards, and every
330+ the middle, the wall, the QR tile lying among the discards, and every
332331 hand no phone has taken, covered-with-a-tap like a hotseat game. With sound
333332 on, the phone in each player's hand is what says their calls and the table
334333 goes quiet for that seat — see [audio, by seat](#audio-by-seat).
⋯ 31 unchanged lines
366365 npm run build && npm run serve # production: dist/ + relay, one process
367366 ```
368367
368+Both commands listen on every interface, and both say where that is:
369+
370+```
371+on this network: http://192.168.86.237:5173
372+```
373+
374+That is the address the QR tile on the felt carries, so `npm run dev` on a
375+laptop and a phone on the same wifi is the whole setup — nothing to configure,
376+nothing to install, no tunnel needed. (Fedora's default firewall zone already
377+allows inbound 1025–65535/tcp; a stricter one wants the port opening.)
378+
369379 The relay always lives at `/ws` on the page's own origin. In development
370380 `vite.config.ts` attaches it to vite's server, so `npm run dev` is the whole
371381 stack; in production `server/index.ts` serves the built `dist/` and the relay
372-from one port (`PORT`, default 8080), listening on the LAN by default so
373-phones on the same wifi can scan straight in. Anything further away wants TLS
374-in front — a reverse proxy with a certificate — since phones only get camera
375-and fullscreen on https.
382+from one port (`PORT`, default 8080). Anything further away than the wifi wants
383+TLS in front — a reverse proxy with a certificate — since phones only get
384+camera and fullscreen on https.
376385
377386 Rooms live in memory. A restart drops them; the next visitor on an old link
378387 recreates the room empty, and a host mid-game republishes its state on the
379-next move. Joining a room writes `?room=` back into the address bar, so the
380-page URL — the host's included — is the invite link.
388+next move. Joining a room writes the room into the address bar, so the page
389+URL — the host's included — is the invite link.
381390
382-A QR pointing at `localhost` is a QR only the host's machine can scan, so the
383-server always gives the invite links a public face: `PUBLIC_URL` if set, else
384-it runs its own rsgrok tunnel (the house ngrok replacement) — spawned
385-the moment the server knows its port, the https URL read off the tunnel-up
386-line, no `:4040` inspection API (another agent may own that port). Every QR
387-and invite link wears that origin instead of the page's. If the tunnel dies,
388-or `rsgrok` is not on the PATH (`RSGROK_BIN` points elsewhere), links fall
389-back to the page's own address and the tunnel is retried on later joins.
391+A QR pointing at `localhost` is a QR only the host's own machine can scan, and
392+that is the one machine that does not need it. So the server gives the invite
393+links a face somebody else can reach, in this order:
394+
395+1. `PUBLIC_URL`, if it is set.
396+2. Its own rsgrok tunnel (the house ngrok replacement) — spawned the moment the
397+ server knows its port, the https URL read off the tunnel-up line, no `:4040`
398+ inspection API (another agent may own that port). It carries furthest and it
399+ carries https, so it wins wherever it is up.
400+3. **This machine's address on the local network.** Ranked 192.168 first, then
401+ the other private ranges, then anything else that is not loopback — a laptop
402+ has a docker bridge and a VPN as well as the wifi, and only one of them is
403+ the one the phones are on. Offered only when the server is actually
404+ listening on every interface, since bound to loopback it would be a link
405+ nothing answers.
406+
407+Every QR and invite link wears that origin instead of the page's, so a table
408+opened at `http://localhost:5173` still hands out a code that works. If the
409+tunnel dies, or `rsgrok` is not on the PATH (`RSGROK_BIN` points elsewhere),
410+the wifi address is what is left, and the tunnel is retried on later joins.
411+
412+**A room link is a path, not a query** — `/UV9WTU`, and `/UV9WTU-1` for a
413+particular seat — and it is uppercase. Both are for the QR's sake: `?` and `=`
414+are outside QR's alphanumeric alphabet, so one of them anywhere in the string
415+forces the whole code into byte mode at eight bits a character instead of
416+five and a half. Uppercased and query-free, `HTTP://192.168.86.237:5173/UV9WTU`
417+is a 25-module code where `http://192.168.86.237:5173/?room=UV9WTU` is 29 — and
418+25 is the floor, since the next size down holds 25 characters and the address
419+alone is 27. One segment, not two, because the page is served with relative
420+asset URLs. `?room=` and `?seat=` are still read, so links people already have
421+keep working, and both servers answer any extensionless page request with the
422+game so the path resolves at all.
423+
424+Identity is a UUID, and it is **not** `crypto.randomUUID` — that exists only in
425+a secure context, which `http://192.168.…` is not, so every phone that scanned
426+the tile would have thrown before it reached the room.
427+`crypto.getRandomValues` is not gated the same way and is what actually
428+generates it.
390429
391430 The tunnel always asks for the same subdomain — `mahjong-table`, or whatever
392431 `TUNNEL_NAME` says — so restarts land back on the URL people already have
⋯ 575 unchanged lines
modifiedserver/index.ts+22 −3
⋯ 30 unchanged lines
3131 '.woff2': 'font/woff2',
3232 };
3333
34+/**
35+ * A room link is a path — `/UV9WTU`, `/UV9WTU-1` — because a QR that has to be
36+ * read off a tile lying on the felt cannot afford `?room=`. See `roomLink` in
37+ * src/net/protocol.ts for why. Nothing is on disk under those names, so any
38+ * request for a page rather than a file is answered with the game, which reads
39+ * the room out of its own address.
40+ */
41+const wantsPage = (req: { headers: { accept?: string } }, pathname: string): boolean =>
42+ !extname(pathname) && (req.headers.accept ?? '').includes('text/html');
43+
3444 const server = createServer(async (req, res) => {
3545 const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
3646 const file = resolve(join(DIST, pathname === '/' ? 'index.html' : pathname));
⋯ 2 unchanged lines
3949 return;
4050 }
4151 try {
42- const s = await stat(file);
52+ // The file if there is one; the game itself if what was asked for is a page
53+ // and there is not. Only then — an extensionless file that does exist is
54+ // still served as itself.
55+ const found = await stat(file).catch(() => null);
56+ const served = found?.isFile()
57+ ? file
58+ : wantsPage(req, pathname)
59+ ? join(DIST, 'index.html')
60+ : file;
61+ const s = found?.isFile() ? found : await stat(served);
4362 if (!s.isFile()) throw new Error('not a file');
44- const ext = extname(file);
63+ const ext = extname(served);
4564 res.writeHead(200, {
4665 'content-type': MIME[ext] ?? 'application/octet-stream',
4766 'content-length': s.size,
⋯ 2 unchanged lines
5069 ? 'public, max-age=31536000, immutable'
5170 : 'no-cache',
5271 });
53- createReadStream(file).pipe(res);
72+ createReadStream(served).pipe(res);
5473 } catch {
5574 res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
5675 }
⋯ 13 unchanged lines
modifiedserver/net.test.mjs+34 −4
⋯ 114 unchanged lines
115115 expect(r.publicBase).toBe('https://ours.vibe.richardscollin.com');
116116 });
117117
118-test('no rsgrok, no link — a join still lands, just local', async () => {
118+test('bound to loopback, no rsgrok — a join still lands, and offers no link', async () => {
119119 // A "binary" that dies at once: the relay must shrug, not stall the hello.
120120 const dead = joinPath(tmpdir(), `dead-rsgrok-${process.pid}.sh`);
121121 writeFileSync(dead, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
⋯ 2 unchanged lines
124124 const relay = createServer();
125125 attachRooms(relay);
126126 await new Promise((r) => relay.listen(0, '127.0.0.1', r));
127+ let joined = null;
128+ try {
129+ joined = await joinRoom({ server: `ws://127.0.0.1:${relay.address().port}/ws` });
130+ // Listening on loopback alone: this machine's wifi address would be a link
131+ // nothing is answering on, so the relay offers none.
132+ expect(joined.publicBase).toBe('');
133+ } finally {
134+ // Left open, the relay's own close never resolves and the failure above
135+ // shows up as a timeout instead of as itself.
136+ joined?.leave();
137+ process.env.RSGROK_BIN = was;
138+ rmSync(dead, { force: true });
139+ await new Promise((r) => relay.close(r));
140+ }
141+});
142+
143+test('on every interface, no rsgrok — the join carries this machine on the wifi', async () => {
144+ const dead = joinPath(tmpdir(), `dead-rsgrok2-${process.pid}.sh`);
145+ writeFileSync(dead, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
146+ const was = process.env.RSGROK_BIN;
147+ process.env.RSGROK_BIN = dead;
148+ const relay = createServer();
149+ attachRooms(relay);
150+ await new Promise((r) => relay.listen(0, '0.0.0.0', r));
151+ const port = relay.address().port;
152+ let joined = null;
127153 try {
128- const r = await joinRoom({ server: `ws://127.0.0.1:${relay.address().port}/ws` });
129- expect(r.publicBase).toBe('');
130- r.leave();
154+ joined = await joinRoom({ server: `ws://127.0.0.1:${port}/ws` });
155+ // A machine with no network at all — a locked-down CI box — has nothing to
156+ // offer and says so; anything else names itself and its port.
157+ if (joined.publicBase !== '') {
158+ expect(joined.publicBase).toMatch(new RegExp(`^http://\\d+\\.\\d+\\.\\d+\\.\\d+:${port}$`));
159+ }
131160 } finally {
161+ joined?.leave();
132162 process.env.RSGROK_BIN = was;
133163 rmSync(dead, { force: true });
134164 await new Promise((r) => relay.close(r));
⋯ 12 unchanged lines
modifiedserver/rooms.ts+93 −10
11 import { spawn, type ChildProcess } from 'node:child_process';
22 import type { IncomingMessage } from 'node:http';
3+import { networkInterfaces } from 'node:os';
34 import type { Duplex } from 'node:stream';
45 import { WebSocketServer, type WebSocket } from 'ws';
56
⋯ 63 unchanged lines
6970
7071 /**
7172 * The public face of this server: a QR pointing at localhost is a QR only the
72- * host's own machine can scan. `PUBLIC_URL` names a face outright; otherwise
73- * we open our own rsgrok tunnel (`RSGROK_BIN` overrides the binary) the
74- * moment the server knows its port, read the https URL off the tunnel-up
75- * line, and hand it to every client on join. A tunnel that dies — network
76- * gone, rsgrok missing — is retried on later joins, at most once per window.
73+ * host's own machine can scan, which is the one machine that does not need it.
74+ * Three answers, in order of how far they carry:
75+ *
76+ * 1. `PUBLIC_URL` names a face outright.
77+ * 2. Our own rsgrok tunnel (`RSGROK_BIN` overrides the binary), opened the
78+ * moment the server knows its port; we read the https URL off the tunnel-up
79+ * line and hand it to every client on join. A tunnel that dies — network
80+ * gone, rsgrok missing — is retried on later joins, at most once per window.
81+ * 3. This machine's address on the local network. It only reaches phones on the
82+ * same wifi, which is exactly who is in the room when four people are sat
83+ * round the laptop — and it needs nothing installed, nothing reachable, and
84+ * no name. It is what the QR falls back to rather than falling back to being
85+ * unscannable.
7786 *
7887 * We always ask for the same subdomain (`TUNNEL_NAME`, default `mahjong-table`), so
7988 * a restart lands back on the URL people already have and we spend one name
⋯ 2 unchanged lines
8291 * without ever printing a URL, and the retry goes out nameless.
8392 */
8493 const TUNNEL_RETRY = 10_000;
85-/** How long a join will wait on a tunnel still shaking hands. */
94+/**
95+ * How long a join will wait on a tunnel still shaking hands — and how long it
96+ * waits when there is a local-network address to fall back on instead.
97+ *
98+ * They differ because what is being risked differs. With nothing else to offer,
99+ * a wait is the difference between a scannable QR and none, and three seconds
100+ * is worth it. With the wifi address already in hand the wait only buys a nicer
101+ * link, and the cost is the tile taking that long to appear on the felt — so it
102+ * is given a moment rather than a pause. A tunnel that comes up later is used
103+ * by every join after it.
104+ */
86105 const TUNNEL_WAIT = 3_000;
106+const TUNNEL_GLANCE = 600;
87107 /** The subdomain we ask for: {name}.vibe.richardscollin.com. */
88108 const TUNNEL_NAME = process.env.TUNNEL_NAME ?? 'mahjong-table';
89109
110+/**
111+ * This machine's address on the local network.
112+ *
113+ * A laptop has several — a docker bridge, a VPN, a virtual switch — and only
114+ * one of them is the wifi the phones are on, so they are ranked by how likely
115+ * they are to be it: the home-router range first, then the other two private
116+ * ranges, then anything else that is not loopback. Link-local (169.254) is
117+ * what an interface says when it has no network at all, so it never counts.
118+ */
119+function lanAddress(): string {
120+ const rank = (ip: string): number => {
121+ if (ip.startsWith('192.168.')) return 0;
122+ if (ip.startsWith('10.')) return 1;
123+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return 2;
124+ return 3;
125+ };
126+ let best = '';
127+ let bestRank = 9;
128+ for (const list of Object.values(networkInterfaces())) {
129+ for (const net of list ?? []) {
130+ // node 18 reports the family as a number; later ones as a string.
131+ const v4 = net.family === 'IPv4' || (net.family as unknown as number) === 4;
132+ if (!v4 || net.internal || net.address.startsWith('169.254.')) continue;
133+ const r = rank(net.address);
134+ if (r < bestRank) {
135+ bestRank = r;
136+ best = net.address;
137+ }
138+ }
139+ }
140+ return best;
141+}
142+
90143 export function attachRooms(server: UpgradeServer, path = '/ws'): void {
91144 const rooms = new Map<string, RoomRec>();
92145 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
⋯ 3 unchanged lines
96149 return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null;
97150 };
98151
152+ /**
153+ * Whether this server is listening on every interface, rather than just
154+ * loopback. Bound to 127.0.0.1 it cannot be reached from the wifi however
155+ * many addresses this machine has, so there is no local link to give out and
156+ * saying otherwise would hand every phone a QR that goes nowhere.
157+ */
158+ const onEveryInterface = (): boolean => {
159+ const addr = server.address?.();
160+ const host = addr && typeof addr === 'object' ? (addr as { address?: string }).address : '';
161+ return host === '0.0.0.0' || host === '::';
162+ };
163+
164+ /** Worked out once: the interfaces do not move while the server is up. */
165+ let lan: string | null = null;
166+ const lanBase = (): string => {
167+ if (!onEveryInterface()) return '';
168+ lan ??= lanAddress();
169+ const port = ownPort();
170+ if (!lan || port === null) return '';
171+ return `http://${lan}:${port}`;
172+ };
173+
99174 let tunnel: ChildProcess | null = null;
100175 let tunnelUrl = '';
101176 let tunnelDiedAt = 0;
⋯ 45 unchanged lines
147222
148223 // Open the tunnel as soon as there is a port to aim it at, not on the
149224 // first join — by the time anyone scans a QR the link should exist.
150- if (ownPort() !== null) ensureTunnel();
151- else server.on('listening', ensureTunnel);
225+ const announce = () => {
226+ ensureTunnel();
227+ const base = lanBase();
228+ if (base) console.log(`on this network: ${base}`);
229+ };
230+ if (ownPort() !== null) announce();
231+ else server.on('listening', announce);
152232
153233 const publicBase = async (): Promise<string> => {
154234 if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL;
155235 ensureTunnel();
156236 if (!tunnelUrl && tunnel) {
237+ const grace = lanBase() ? TUNNEL_GLANCE : TUNNEL_WAIT;
157238 await new Promise<void>((resolve) => {
158239 waiters.push(resolve);
159- setTimeout(resolve, TUNNEL_WAIT).unref();
240+ setTimeout(resolve, grace).unref();
160241 });
161242 }
162- return tunnelUrl;
243+ // The tunnel carries further and carries https, so it wins where it is up.
244+ // Where it is not, the wifi everybody is already on is a real answer.
245+ return tunnelUrl || lanBase();
163246 };
164247
165248 server.on('upgrade', (req, socket, head) => {
⋯ 170 unchanged lines
modifiedsrc/net/protocol.ts+76 −2
⋯ 118 unchanged lines
119119 { ids: [], name: '' },
120120 ];
121121
122+/**
123+ * What a room link looks like, and how few modules it can be said in.
124+ *
125+ * The QR is a tile lying on the felt at the size every other tile is, so the
126+ * code has one tile's width to be read in and every module counts. Two things
127+ * buy modules:
128+ *
129+ * **The room goes in the path, not the query.** `?room=` and `&seat=` cost the
130+ * `?`, the `=`, the `&` and four letters of the word — and worse, `?` and `=`
131+ * are not in QR's alphanumeric set, so one of them anywhere in the string
132+ * forces the whole code into byte mode.
133+ *
134+ * **The link is uppercase.** QR's alphanumeric mode packs two characters into
135+ * eleven bits against byte mode's eight bits each, but its alphabet is only
136+ * `0-9 A-Z $%*+-./: ` and a space. Uppercased, a room link is entirely inside
137+ * it. Schemes and hostnames are case-insensitive by spec and room codes are
138+ * uppercase already, so nothing is lost saying it loudly.
139+ *
140+ * Together those take `http://192.168.1.9:5199/?room=UV9WTU` from a version 3
141+ * code at 29 modules to `HTTP://192.168.1.9:5199/UV9WTU` at 25 — each module
142+ * 16% wider on the same tile. 21 is the next size down and holds 25
143+ * alphanumeric characters, which an address and a code cannot fit inside, so
144+ * 25 is the floor for a link a phone will actually open.
145+ *
146+ * A seat's own link is the same path with `-N` on the end rather than a second
147+ * segment, because the page is served with relative asset URLs: one segment
148+ * deep they resolve against the root, two and they resolve against a directory
149+ * that does not exist.
150+ *
151+ * `?room=` and `?seat=` are still read. Links people already have keep working.
152+ */
153+const ROOM_SEGMENT = /^([A-Z0-9]{4,10})(?:-([0-3]))?$/;
154+
155+/** The room segment at the end of the page's path, if that is what it is. */
156+function pathRoom(): { code: string; seat: SeatId | null } | null {
157+ try {
158+ const last = window.location.pathname.split('/').filter(Boolean).pop() ?? '';
159+ const m = ROOM_SEGMENT.exec(last.toUpperCase());
160+ if (!m) return null;
161+ return { code: m[1], seat: m[2] === undefined ? null : (Number(m[2]) as SeatId) };
162+ } catch {
163+ return null;
164+ }
165+}
166+
167+/**
168+ * Where the game itself is served from — the page's path with any room segment
169+ * taken off it, so a link can be built whether this page arrived as `/`, as
170+ * `/UV9WTU`, or from under some prefix a reverse proxy put it behind.
171+ */
172+function appBase(): string {
173+ try {
174+ const parts = window.location.pathname.split('/').filter(Boolean);
175+ if (parts.length && ROOM_SEGMENT.test(parts[parts.length - 1].toUpperCase())) parts.pop();
176+ return parts.length ? `/${parts.join('/')}/` : '/';
177+ } catch {
178+ return '/';
179+ }
180+}
181+
182+/** This page's own address for a room, for the address-bar rewrite on join. */
183+export function roomPath(code: string, seat: SeatId | null): string {
184+ return `${appBase()}${code}${seat === null ? '' : `-${seat}`}`;
185+}
186+
122187 /** The seat in the QR's link, if the hosting page passed it through to us. */
123188 export function seatFromUrl(): SeatId | null {
189+ const path = pathRoom();
190+ if (path?.seat !== null && path?.seat !== undefined) return path.seat;
124191 try {
125192 const raw = new URLSearchParams(window.location.search).get('seat');
126193 if (raw === null) return null;
⋯ 6 unchanged lines
133200
134201 /** The room in the page URL — a shared link, or our own join writing it back. */
135202 export function roomFromUrl(): string | null {
203+ const path = pathRoom();
204+ if (path) return path.code;
136205 try {
137206 return new URLSearchParams(window.location.search).get('room');
138207 } catch {
⋯ 29 unchanged lines
168237 // A malformed base loses to a working local link.
169238 }
170239 }
171- u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`;
240+ u.pathname = `${appBase()}${code}${seat === undefined ? '' : `-${seat}`}`;
241+ u.search = '';
172242 u.hash = '';
173- return u.toString();
243+ // Said loudly, which is what lets the QR say it in 25 modules instead of 29.
244+ // A URL's scheme and host are case-insensitive and the rest of this one is a
245+ // room code, which is uppercase where it is made — see `CODE_ALPHABET` in
246+ // server/rooms.ts.
247+ return u.toString().toUpperCase();
174248 }
modifiedsrc/net/room.ts+29 −2
⋯ 58 unchanged lines
5959 return `${proto}//${window.location.host}/ws`;
6060 }
6161
62+/**
63+ * A UUID, without needing a secure context.
64+ *
65+ * `crypto.randomUUID` exists only on https and localhost, and the room links
66+ * this game hands out are `http://192.168.…` — the wifi everybody is on, which
67+ * is not a secure context. So a phone that scanned the tile on the table would
68+ * have thrown here, before it ever reached the room, and the table would have
69+ * sat there with a QR nobody could use. `getRandomValues` is not gated the same
70+ * way and is the real source; the last fallback is for a browser with neither,
71+ * and this is a key into a seat map rather than anything anybody could gain by
72+ * guessing.
73+ */
74+function uuid(): string {
75+ const c = globalThis.crypto as Crypto | undefined;
76+ if (typeof c?.randomUUID === 'function') return c.randomUUID();
77+ const b = new Uint8Array(16);
78+ if (typeof c?.getRandomValues === 'function') c.getRandomValues(b);
79+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
80+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
81+ b[8] = (b[8] & 0x3f) | 0x80; // variant 1
82+ const hex = [...b].map((n) => n.toString(16).padStart(2, '0')).join('');
83+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
84+ 16,
85+ 20,
86+ )}-${hex.slice(20)}`;
87+}
88+
6289 function myId(): string {
6390 try {
6491 const key = 'mahjong-net-id';
6592 let id = sessionStorage.getItem(key);
6693 if (!id) {
67- id = crypto.randomUUID();
94+ id = uuid();
6895 sessionStorage.setItem(key, id);
6996 }
7097 return id;
7198 } catch {
72- return crypto.randomUUID();
99+ return uuid();
73100 }
74101 }
75102
⋯ 250 unchanged lines
modifiedsrc/net/session.ts+9 −4
⋯ 11 unchanged lines
1212 type FxMsg,
1313 type SeatMsg,
1414 roomFromUrl,
15+ roomPath,
16+ seatFromUrl,
1517 type SeatSlot,
1618 type VoiceMsg,
1719 } from './protocol';
⋯ 156 unchanged lines
174176 */
175177 static async join(engine: Game): Promise<NetSession> {
176178 const room = await joinRoom({ room: roomFromUrl() ?? undefined });
177- // The room code goes into the address bar: the page's own URL becomes the
178- // invite link, and a reload walks back into the same room — and, with the
179- // id this device keeps, the same seat.
179+ // The room code goes into the address bar, in the same short path form the
180+ // QR carries: the page's own URL becomes the invite link, and a reload
181+ // walks back into the same room — and, with the id this device keeps, the
182+ // same seat. A seat the link named is kept, so a phone that reloads lands
183+ // back on the hand it was holding rather than on the picker.
180184 try {
181185 const u = new URL(window.location.href);
182- u.searchParams.set('room', room.code);
186+ u.pathname = roomPath(room.code, seatFromUrl());
187+ u.search = '';
183188 window.history.replaceState(null, '', u);
184189 } catch {
185190 // A sandbox that refuses history rewrites still gets to play.
⋯ 391 unchanged lines
modifiedsrc/table/pool.ts+6 −6
⋯ 93 unchanged lines
9494 const LOOSE_LIMIT = 60;
9595
9696 /**
97- * How much bigger than a discard the join tile is drawn.
98- *
99- * A QR has to be read by a phone held over the table, and a tile in the pool is
100- * drawn the size it was in the wall. Big enough to scan without leaning in,
101- * small enough to still be a tile somebody could pick up and put down again.
97+ * The join tile is the size every other tile is — `geo.tile`, which is the size
98+ * of a tile in a hand, because they are the same tiles. It was drawn bigger for
99+ * a while so the code would be easier to read, and a tile in the middle that is
100+ * not tile-sized reads as a card lying on the table rather than as one of the
101+ * set. A phone leans in the extra inch.
102102 */
103-const JOIN_SCALE = 2.4;
103+const JOIN_SCALE = 1;
104104 /** How it lies. Nothing on a table is ever quite square to it. */
105105 const JOIN_ANGLE = -0.07;
106106
⋯ 785 unchanged lines
modifiedsrc/ui/Pool.tsx+3 −5
⋯ 355 unchanged lines
356356 if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
357357
358358 const newest = pool.newest;
359- // The one tile whose face is painted rather than cut out of the sheet. Asked
360- // for at the size it is actually being drawn, so the code is never resampled
361- // and a phone never has to work at it.
359+ // The one tile whose face is painted rather than cut out of the sheet. It is
360+ // made once per link, at its own resolution, and drawn down to the tile.
362361 const join = pool.joinTile;
363- const joinArt =
364- join && joinUrl ? qrFace(joinUrl, join.w * join.scale * (window.devicePixelRatio || 1)) : null;
362+ const joinArt = join && joinUrl ? qrFace(joinUrl) : null;
365363 for (const b of bodies) {
366364 drawTile(ctx, art, {
367365 x: b.x,
⋯ 170 unchanged lines
modifiedsrc/ui/tileArt.ts+37 −42
⋯ 152 unchanged lines
153153 * light over the table shades them, and it throws the same shadow on the felt.
154154 *
155155 * The code is square and a tile is not, so it sits in the square across the
156- * width and the white left over above and below carries SCAN and JOIN — the
157- * two bands a suit tile spends on its number and its suit, spent here on
158- * saying what the pattern between them is for. The ink is the tiles' own dark
159- * green rather than black: a QR reads on any sufficient contrast, and black on
160- * white would be the one thing on this table not made of the same two colours
161- * as everything else.
156+ * width with the white left over showing above and below, and nothing is
157+ * written in it. The ink is the tiles' own dark green rather than black: a QR
158+ * reads on any sufficient contrast, and black on white would be the one thing
159+ * on this table not made of the same two colours as everything else.
162160 */
163161 const QR_INK = '#14402f';
164162 /** The face is the white the sheet paints every other face — this tile is one
165163 * of the set, not a card that turned up in it. */
166164 const QR_GROUND = '#ffffff';
167-/** How much of the tile's width the code takes, leaving a quiet margin. A QR
168- * needs one, and a tile's face has an edge the art already leaves clear. */
165+/**
166+ * How much of the tile's width the code takes.
167+ *
168+ * The rest is the quiet zone a QR needs around it, and it is the number this
169+ * cannot be greedy about: at 0.8 the white either side is a little over three
170+ * modules, which is close to the four the spec asks for. Pushing it to 0.88
171+ * would buy a tenth on each module and leave under two, and a code with no air
172+ * round it is a code a phone gives up on — the opposite of the trade worth
173+ * making here.
174+ */
169175 const QR_INSET = 0.8;
170-/** The two words, as tall as the band they sit in allows. */
171-const QR_LABEL = 0.13;
172176
177+/**
178+ * Pixels per module in the painted face. The face is sized from this rather
179+ * than from how big the tile happens to be drawn, so a module is always a whole
180+ * number of pixels and the code always fills exactly `QR_INSET` of the width.
181+ *
182+ * Sizing it the other way round — a canvas the width of the tile, modules
183+ * floored into it — is what it did first, and at a tile 33px wide that floored
184+ * to one pixel a module and left the code covering 42% of the face instead of
185+ * 80%. The canvas is cheap, made once per link, and drawn down to whatever size
186+ * the tile is by the same resampler that handles every other face.
187+ */
188+const QR_UNIT = 8;
189+
173190 const qrFaces = new Map<string, HTMLCanvasElement>();
174191
175-export function qrFace(url: string, width: number): HTMLCanvasElement | null {
176- // Rounded, so a pool that re-measures by a pixel does not repaint the code.
177- const w = Math.max(64, Math.round(width / 16) * 16);
178- const key = `${w}:${url}`;
179- const had = qrFaces.get(key);
192+export function qrFace(url: string): HTMLCanvasElement | null {
193+ const had = qrFaces.get(url);
180194 if (had) return had;
181195
182196 let code: { size: number; data: boolean[][] };
183197 try {
184- code = encode(url) as { size: number; data: boolean[][] };
198+ // No quiet zone of its own: the tile's white face is the quiet zone, and
199+ // at QR_INSET it is about three and a half modules of it on every side —
200+ // more than the one uqr would have added, and it costs no modules.
201+ code = encode(url, { border: 0 }) as { size: number; data: boolean[][] };
185202 } catch {
186203 // An unencodable link is not worth taking the table down over.
187204 return null;
188205 }
189206
207+ const side = QR_UNIT * code.size;
208+ const w = Math.round(side / QR_INSET);
190209 const h = Math.round(w * 1.375);
191210 const canvas = document.createElement('canvas');
192211 canvas.width = w;
⋯ 4 unchanged lines
197216 ctx.fillStyle = QR_GROUND;
198217 ctx.fillRect(0, 0, w, h);
199218
200- // Whole pixels per module, so no two modules end up different widths — a
201- // resampled QR is a QR a phone has to work at.
202- const span = w * QR_INSET;
203- const unit = Math.max(1, Math.floor(span / code.size));
204- const side = unit * code.size;
205219 const x0 = Math.round((w - side) / 2);
206220 const y0 = Math.round((h - side) / 2);
207221 ctx.fillStyle = QR_INK;
208222 for (let r = 0; r < code.size; r++) {
209223 for (let c = 0; c < code.size; c++) {
210- if (code.data[r][c]) ctx.fillRect(x0 + c * unit, y0 + r * unit, unit, unit);
224+ if (code.data[r][c]) ctx.fillRect(x0 + c * QR_UNIT, y0 + r * QR_UNIT, QR_UNIT, QR_UNIT);
211225 }
212226 }
213227
214- // One word in each band, centred in it. Letter-spaced, because two short
215- // words set small want the air — and because it is what the English under
216- // every Chinese label on this table already does.
217- const size = w * QR_LABEL;
218- ctx.font = `600 ${size}px ui-sans-serif, system-ui, "Segoe UI", sans-serif`;
219- ctx.textAlign = 'center';
220- ctx.textBaseline = 'middle';
221- // Not everywhere; where it is missing the words simply set tighter.
222- try {
223- ctx.letterSpacing = `${size * 0.18}px`;
224- } catch {
225- // older engines
226- }
227- // Nudged right by half the tracking: the last letter's own trailing space is
228- // counted into the width, and centred text hangs off to the left without it.
229- const nudge = size * 0.09;
230- ctx.fillText('SCAN', w / 2 + nudge, y0 / 2);
231- ctx.fillText('JOIN', w / 2 + nudge, h - y0 / 2);
232-
233- qrFaces.set(key, canvas);
228+ qrFaces.set(url, canvas);
234229 return canvas;
235230 }
236231
⋯ 312 unchanged lines
modifiedvite.config.ts+4 −1
⋯ 23 unchanged lines
2424 // portless hands us PORT/HOST; vite doesn't read them on its own
2525 server: {
2626 port: process.env.PORT ? Number(process.env.PORT) : undefined,
27- host: process.env.HOST || undefined,
27+ // Every address, not just loopback. The QR on the table carries this
28+ // machine's address on the wifi (see `lanAddress` in server/rooms.ts), and
29+ // a link nothing is listening on is worse than no link at all.
30+ host: process.env.HOST || true,
2831 // Tunnels and LAN names have to get past vite's Host check, or every
2932 // phone that scans the QR meets a 403.
3033 allowedHosts: [
⋯ 9 unchanged lines