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 | |||
| 16 | 16 | ||
| 17 | 17 | - somebody sits down at this screen and **taps a hand the computer has been | |
| 18 | 18 | 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; | |
| 23 | 22 | - somebody **opens the invite link** from three cities away and takes one from | |
| 24 | 23 | their own laptop, with the table turned so their seat is the near edge. | |
| 25 | 24 | ||
| ⋯ 230 unchanged lines | |||
| 256 | 255 | around a live `Game`, not a second one, so a room arriving mid-hand changes | |
| 257 | 256 | nothing on screen except that the QR tile appears. | |
| 258 | 257 | ||
| 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 | |
| 262 | 261 | is a body in the pool like any other (`TablePool.joinTile`): a discard thrown | |
| 263 | 262 | at it knocks it aside and turns it, it can be picked up and put somewhere less | |
| 264 | 263 | in the way or flicked off across the cloth, the same one light over the table | |
| ⋯ 63 unchanged lines | |||
| 328 | 327 | way in, and the ones somebody already holds, which is how two people play one | |
| 329 | 328 | hand off two devices. A seat's own QR names its chair and skips the picker. | |
| 330 | 329 | - **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 | |
| 332 | 331 | hand no phone has taken, covered-with-a-tap like a hotseat game. With sound | |
| 333 | 332 | on, the phone in each player's hand is what says their calls and the table | |
| 334 | 333 | goes quiet for that seat — see [audio, by seat](#audio-by-seat). | |
| ⋯ 31 unchanged lines | |||
| 366 | 365 | npm run build && npm run serve # production: dist/ + relay, one process | |
| 367 | 366 | ``` | |
| 368 | 367 | ||
| 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 | + | ||
| 369 | 379 | The relay always lives at `/ws` on the page's own origin. In development | |
| 370 | 380 | `vite.config.ts` attaches it to vite's server, so `npm run dev` is the whole | |
| 371 | 381 | 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. | |
| 376 | 385 | ||
| 377 | 386 | Rooms live in memory. A restart drops them; the next visitor on an old link | |
| 378 | 387 | 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. | |
| 381 | 390 | ||
| 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. | |
| 390 | 429 | ||
| 391 | 430 | The tunnel always asks for the same subdomain — `mahjong-table`, or whatever | |
| 392 | 431 | `TUNNEL_NAME` says — so restarts land back on the URL people already have | |
| ⋯ 575 unchanged lines | |||
modifiedserver/index.ts+22 −3
| ⋯ 30 unchanged lines | |||
| 31 | 31 | '.woff2': 'font/woff2', | |
| 32 | 32 | }; | |
| 33 | 33 | ||
| 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 | + | ||
| 34 | 44 | const server = createServer(async (req, res) => { | |
| 35 | 45 | const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname); | |
| 36 | 46 | const file = resolve(join(DIST, pathname === '/' ? 'index.html' : pathname)); | |
| ⋯ 2 unchanged lines | |||
| 39 | 49 | return; | |
| 40 | 50 | } | |
| 41 | 51 | 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); | |
| 43 | 62 | if (!s.isFile()) throw new Error('not a file'); | |
| 44 | - | const ext = extname(file); | |
| 63 | + | const ext = extname(served); | |
| 45 | 64 | res.writeHead(200, { | |
| 46 | 65 | 'content-type': MIME[ext] ?? 'application/octet-stream', | |
| 47 | 66 | 'content-length': s.size, | |
| ⋯ 2 unchanged lines | |||
| 50 | 69 | ? 'public, max-age=31536000, immutable' | |
| 51 | 70 | : 'no-cache', | |
| 52 | 71 | }); | |
| 53 | - | createReadStream(file).pipe(res); | |
| 72 | + | createReadStream(served).pipe(res); | |
| 54 | 73 | } catch { | |
| 55 | 74 | res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n'); | |
| 56 | 75 | } | |
| ⋯ 13 unchanged lines | |||
modifiedserver/net.test.mjs+34 −4
| ⋯ 114 unchanged lines | |||
| 115 | 115 | expect(r.publicBase).toBe('https://ours.vibe.richardscollin.com'); | |
| 116 | 116 | }); | |
| 117 | 117 | ||
| 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 () => { | |
| 119 | 119 | // A "binary" that dies at once: the relay must shrug, not stall the hello. | |
| 120 | 120 | const dead = joinPath(tmpdir(), `dead-rsgrok-${process.pid}.sh`); | |
| 121 | 121 | writeFileSync(dead, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); | |
| ⋯ 2 unchanged lines | |||
| 124 | 124 | const relay = createServer(); | |
| 125 | 125 | attachRooms(relay); | |
| 126 | 126 | 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; | |
| 127 | 153 | 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 | + | } | |
| 131 | 160 | } finally { | |
| 161 | + | joined?.leave(); | |
| 132 | 162 | process.env.RSGROK_BIN = was; | |
| 133 | 163 | rmSync(dead, { force: true }); | |
| 134 | 164 | await new Promise((r) => relay.close(r)); | |
| ⋯ 12 unchanged lines | |||
modifiedserver/rooms.ts+93 −10
| 1 | 1 | import { spawn, type ChildProcess } from 'node:child_process'; | |
| 2 | 2 | import type { IncomingMessage } from 'node:http'; | |
| 3 | + | import { networkInterfaces } from 'node:os'; | |
| 3 | 4 | import type { Duplex } from 'node:stream'; | |
| 4 | 5 | import { WebSocketServer, type WebSocket } from 'ws'; | |
| 5 | 6 | ||
| ⋯ 63 unchanged lines | |||
| 69 | 70 | ||
| 70 | 71 | /** | |
| 71 | 72 | * 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. | |
| 77 | 86 | * | |
| 78 | 87 | * We always ask for the same subdomain (`TUNNEL_NAME`, default `mahjong-table`), so | |
| 79 | 88 | * a restart lands back on the URL people already have and we spend one name | |
| ⋯ 2 unchanged lines | |||
| 82 | 91 | * without ever printing a URL, and the retry goes out nameless. | |
| 83 | 92 | */ | |
| 84 | 93 | 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 | + | */ | |
| 86 | 105 | const TUNNEL_WAIT = 3_000; | |
| 106 | + | const TUNNEL_GLANCE = 600; | |
| 87 | 107 | /** The subdomain we ask for: {name}.vibe.richardscollin.com. */ | |
| 88 | 108 | const TUNNEL_NAME = process.env.TUNNEL_NAME ?? 'mahjong-table'; | |
| 89 | 109 | ||
| 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 | + | ||
| 90 | 143 | export function attachRooms(server: UpgradeServer, path = '/ws'): void { | |
| 91 | 144 | const rooms = new Map<string, RoomRec>(); | |
| 92 | 145 | const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 }); | |
| ⋯ 3 unchanged lines | |||
| 96 | 149 | return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null; | |
| 97 | 150 | }; | |
| 98 | 151 | ||
| 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 | + | ||
| 99 | 174 | let tunnel: ChildProcess | null = null; | |
| 100 | 175 | let tunnelUrl = ''; | |
| 101 | 176 | let tunnelDiedAt = 0; | |
| ⋯ 45 unchanged lines | |||
| 147 | 222 | ||
| 148 | 223 | // Open the tunnel as soon as there is a port to aim it at, not on the | |
| 149 | 224 | // 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); | |
| 152 | 232 | ||
| 153 | 233 | const publicBase = async (): Promise<string> => { | |
| 154 | 234 | if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL; | |
| 155 | 235 | ensureTunnel(); | |
| 156 | 236 | if (!tunnelUrl && tunnel) { | |
| 237 | + | const grace = lanBase() ? TUNNEL_GLANCE : TUNNEL_WAIT; | |
| 157 | 238 | await new Promise<void>((resolve) => { | |
| 158 | 239 | waiters.push(resolve); | |
| 159 | - | setTimeout(resolve, TUNNEL_WAIT).unref(); | |
| 240 | + | setTimeout(resolve, grace).unref(); | |
| 160 | 241 | }); | |
| 161 | 242 | } | |
| 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(); | |
| 163 | 246 | }; | |
| 164 | 247 | ||
| 165 | 248 | server.on('upgrade', (req, socket, head) => { | |
| ⋯ 170 unchanged lines | |||
modifiedsrc/net/protocol.ts+76 −2
| ⋯ 118 unchanged lines | |||
| 119 | 119 | { ids: [], name: '' }, | |
| 120 | 120 | ]; | |
| 121 | 121 | ||
| 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 | + | ||
| 122 | 187 | /** The seat in the QR's link, if the hosting page passed it through to us. */ | |
| 123 | 188 | export function seatFromUrl(): SeatId | null { | |
| 189 | + | const path = pathRoom(); | |
| 190 | + | if (path?.seat !== null && path?.seat !== undefined) return path.seat; | |
| 124 | 191 | try { | |
| 125 | 192 | const raw = new URLSearchParams(window.location.search).get('seat'); | |
| 126 | 193 | if (raw === null) return null; | |
| ⋯ 6 unchanged lines | |||
| 133 | 200 | ||
| 134 | 201 | /** The room in the page URL — a shared link, or our own join writing it back. */ | |
| 135 | 202 | export function roomFromUrl(): string | null { | |
| 203 | + | const path = pathRoom(); | |
| 204 | + | if (path) return path.code; | |
| 136 | 205 | try { | |
| 137 | 206 | return new URLSearchParams(window.location.search).get('room'); | |
| 138 | 207 | } catch { | |
| ⋯ 29 unchanged lines | |||
| 168 | 237 | // A malformed base loses to a working local link. | |
| 169 | 238 | } | |
| 170 | 239 | } | |
| 171 | - | u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`; | |
| 240 | + | u.pathname = `${appBase()}${code}${seat === undefined ? '' : `-${seat}`}`; | |
| 241 | + | u.search = ''; | |
| 172 | 242 | 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(); | |
| 174 | 248 | } | |
modifiedsrc/net/room.ts+29 −2
| ⋯ 58 unchanged lines | |||
| 59 | 59 | return `${proto}//${window.location.host}/ws`; | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 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 | + | ||
| 62 | 89 | function myId(): string { | |
| 63 | 90 | try { | |
| 64 | 91 | const key = 'mahjong-net-id'; | |
| 65 | 92 | let id = sessionStorage.getItem(key); | |
| 66 | 93 | if (!id) { | |
| 67 | - | id = crypto.randomUUID(); | |
| 94 | + | id = uuid(); | |
| 68 | 95 | sessionStorage.setItem(key, id); | |
| 69 | 96 | } | |
| 70 | 97 | return id; | |
| 71 | 98 | } catch { | |
| 72 | - | return crypto.randomUUID(); | |
| 99 | + | return uuid(); | |
| 73 | 100 | } | |
| 74 | 101 | } | |
| 75 | 102 | ||
| ⋯ 250 unchanged lines | |||
modifiedsrc/net/session.ts+9 −4
| ⋯ 11 unchanged lines | |||
| 12 | 12 | type FxMsg, | |
| 13 | 13 | type SeatMsg, | |
| 14 | 14 | roomFromUrl, | |
| 15 | + | roomPath, | |
| 16 | + | seatFromUrl, | |
| 15 | 17 | type SeatSlot, | |
| 16 | 18 | type VoiceMsg, | |
| 17 | 19 | } from './protocol'; | |
| ⋯ 156 unchanged lines | |||
| 174 | 176 | */ | |
| 175 | 177 | static async join(engine: Game): Promise<NetSession> { | |
| 176 | 178 | 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. | |
| 180 | 184 | try { | |
| 181 | 185 | const u = new URL(window.location.href); | |
| 182 | - | u.searchParams.set('room', room.code); | |
| 186 | + | u.pathname = roomPath(room.code, seatFromUrl()); | |
| 187 | + | u.search = ''; | |
| 183 | 188 | window.history.replaceState(null, '', u); | |
| 184 | 189 | } catch { | |
| 185 | 190 | // A sandbox that refuses history rewrites still gets to play. | |
| ⋯ 391 unchanged lines | |||
modifiedsrc/table/pool.ts+6 −6
| ⋯ 93 unchanged lines | |||
| 94 | 94 | const LOOSE_LIMIT = 60; | |
| 95 | 95 | ||
| 96 | 96 | /** | |
| 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. | |
| 102 | 102 | */ | |
| 103 | - | const JOIN_SCALE = 2.4; | |
| 103 | + | const JOIN_SCALE = 1; | |
| 104 | 104 | /** How it lies. Nothing on a table is ever quite square to it. */ | |
| 105 | 105 | const JOIN_ANGLE = -0.07; | |
| 106 | 106 | ||
| ⋯ 785 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+3 −5
| ⋯ 355 unchanged lines | |||
| 356 | 356 | if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0); | |
| 357 | 357 | ||
| 358 | 358 | 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. | |
| 362 | 361 | 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; | |
| 365 | 363 | for (const b of bodies) { | |
| 366 | 364 | drawTile(ctx, art, { | |
| 367 | 365 | x: b.x, | |
| ⋯ 170 unchanged lines | |||
modifiedsrc/ui/tileArt.ts+37 −42
| ⋯ 152 unchanged lines | |||
| 153 | 153 | * light over the table shades them, and it throws the same shadow on the felt. | |
| 154 | 154 | * | |
| 155 | 155 | * 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. | |
| 162 | 160 | */ | |
| 163 | 161 | const QR_INK = '#14402f'; | |
| 164 | 162 | /** The face is the white the sheet paints every other face — this tile is one | |
| 165 | 163 | * of the set, not a card that turned up in it. */ | |
| 166 | 164 | 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 | + | */ | |
| 169 | 175 | const QR_INSET = 0.8; | |
| 170 | - | /** The two words, as tall as the band they sit in allows. */ | |
| 171 | - | const QR_LABEL = 0.13; | |
| 172 | 176 | ||
| 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 | + | ||
| 173 | 190 | const qrFaces = new Map<string, HTMLCanvasElement>(); | |
| 174 | 191 | ||
| 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); | |
| 180 | 194 | if (had) return had; | |
| 181 | 195 | ||
| 182 | 196 | let code: { size: number; data: boolean[][] }; | |
| 183 | 197 | 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[][] }; | |
| 185 | 202 | } catch { | |
| 186 | 203 | // An unencodable link is not worth taking the table down over. | |
| 187 | 204 | return null; | |
| 188 | 205 | } | |
| 189 | 206 | ||
| 207 | + | const side = QR_UNIT * code.size; | |
| 208 | + | const w = Math.round(side / QR_INSET); | |
| 190 | 209 | const h = Math.round(w * 1.375); | |
| 191 | 210 | const canvas = document.createElement('canvas'); | |
| 192 | 211 | canvas.width = w; | |
| ⋯ 4 unchanged lines | |||
| 197 | 216 | ctx.fillStyle = QR_GROUND; | |
| 198 | 217 | ctx.fillRect(0, 0, w, h); | |
| 199 | 218 | ||
| 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; | |
| 205 | 219 | const x0 = Math.round((w - side) / 2); | |
| 206 | 220 | const y0 = Math.round((h - side) / 2); | |
| 207 | 221 | ctx.fillStyle = QR_INK; | |
| 208 | 222 | for (let r = 0; r < code.size; r++) { | |
| 209 | 223 | 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); | |
| 211 | 225 | } | |
| 212 | 226 | } | |
| 213 | 227 | ||
| 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); | |
| 234 | 229 | return canvas; | |
| 235 | 230 | } | |
| 236 | 231 | ||
| ⋯ 312 unchanged lines | |||
modifiedvite.config.ts+4 −1
| ⋯ 23 unchanged lines | |||
| 24 | 24 | // portless hands us PORT/HOST; vite doesn't read them on its own | |
| 25 | 25 | server: { | |
| 26 | 26 | 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, | |
| 28 | 31 | // Tunnels and LAN names have to get past vite's Host check, or every | |
| 29 | 32 | // phone that scans the QR meets a 403. | |
| 30 | 33 | allowedHosts: [ | |
| ⋯ 9 unchanged lines | |||