anvilsign in

collin/mahjong · f55b8ad0

Eject antics: our own websocket relay in node

Collin Richards · 2026-08-20 16:46 UTC · f55b8ad0e3652a507739502125725eeed521af0a · parent 3f4c297b · browse files

modifiedREADME.md+43 −30
⋯ 142 unchanged lines
143143
144144 ## Playing online
145145
146-Two networked modes, both built on [antics](https://antics.gg) — a room
147-service for web games with no backend of your own: one client (the antics
148-*host*) runs the engine, publishes the whole `GameState` as room state after
146+Two networked modes, both built on a room relay of our own — `server/rooms.ts`,
147+a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong:
148+rooms with codes, host election by join order, one bag of shared state only the
149+host may write, and events fanned out to everyone else. One client — the
150+*host* — runs the engine, publishes the whole `GameState` as room state after
149151 every move, and plays intents the other devices send as events. The engine was
150152 already a pure JSON state machine, so nothing in `src/game/` knows the network
151-exists — the same trick `autoplay.ts` plays, stretched over a wire. All of it
152-lives in `src/net/`.
153+exists — the same trick `autoplay.ts` plays, stretched over a wire. The client
154+end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game
155+riding it.
153156
154157 - **線上對戰 Online game** — everyone on their own device. Whoever opens the
155158 room picks 線上對戰 and gets a seat lobby; friends who open the same link
⋯ 22 unchanged lines
178181 — the state carries a multiset and each device keeps its own order
179182 (`src/net/handOrder.ts`), because a round-trip inside a drag gesture is lag
180183 you can feel.
181-- **The host can change.** antics re-elects when the host leaves; every other
182- client already mirrors the full state, so the new host starts its engine
183- from what it was just watching and the game carries on.
184+- **The host can change.** The relay re-elects — the earliest joiner still
185+ connected — when the host leaves; every other client already mirrors the
186+ full state, so the new host starts its engine from what it was just watching
187+ and the game carries on.
184188 - **A hidden host must keep hosting.** Browsers suspend requestAnimationFrame
185- in background tabs, and the antics SDK flushes state writes off rAF — so a
186- party screen somebody tabbed away from would silently stop answering.
187- `src/net/clock.ts` feeds the SDK's `__anticsClock__` injection point from a
188- Web Worker, whose timers are not throttled.
189-- **Fairness is social, not cryptographic.** The classic tier shares the whole
190- state with every client, wall and hands included — a friend with devtools
191- open can cheat. The antics sim tier would fix that, and costs money.
189+ and throttle timers in background tabs, so nothing in `src/net/` runs on a
190+ loop: state writes flush on a microtask, everything inbound arrives over the
191+ WebSocket (whose delivery is not throttled), and dead connections are the
192+ server's ping loop's problem. A party screen somebody tabbed away from
193+ keeps answering.
194+- **Identity is a UUID in sessionStorage**, chosen by the device and taken at
195+ its word by the relay. The seat map is keyed on it, so a reload or a wifi
196+ blip walks back into its own seat. No auth — this is a home server for one
197+ table of friends.
198+- **Fairness is social, not cryptographic.** Room state carries the whole
199+ game, wall and hands included — a friend with devtools open can cheat.
200+ Moving the engine into the relay would fix that; the server is ours now, so
201+ only the work stands in the way (TODO).
192202
193-### Hosting on antics
203+### Running it
194204
195205 ```
196-npm run build && node scripts/antics-deploy.mjs # keyless — a fresh link each deploy
197-node scripts/antics-deploy.mjs --project <id> # stable /p/ link (needs `npx antics-cli login`)
206+npm run dev # vite, with the relay on /ws of the same origin
207+npm run build && npm run serve # production: dist/ + relay, one process
198208 ```
199209
200-The deploy script packs the 57 voice clips into one `voice/pack.json` (an
201-antics bundle holds at most 50 files; `sound.ts` prefers the pack when it is
202-there). Keyless deploys are pinned to their content hash — share a new link
203-after every deploy — and keyless rooms hold 8 players and expire after 24
204-hours. Deploying under a project gives a share link that follows updates.
210+The relay always lives at `/ws` on the page's own origin. In development
211+`vite.config.ts` attaches it to vite's server, so `npm run dev` is the whole
212+stack; in production `server/index.ts` serves the built `dist/` and the relay
213+from one port (`PORT`, default 8080), listening on the LAN by default so
214+phones on the same wifi can scan straight in. Anything further away wants TLS
215+in front — a reverse proxy with a certificate — since phones only get camera
216+and fullscreen on https.
205217
206-The QR links point at the game's own URL (`/raw/<hash>/?room=…&seat=…`), not
207-the antics room page: the room page forwards only the parameters it knows
208-about to the game, and `seat` is not one of them — and a phone is better off
209-without the chrome anyway.
218+Rooms live in memory. A restart drops them; the next visitor on an old link
219+recreates the room empty, and a host mid-game republishes its state on the
220+next move. Joining a room writes `?room=` back into the address bar, so the
221+page URL — the host's included — is the invite link.
210222
211223 ## On a phone
212224
⋯ 220 unchanged lines
433445 src/game/engine.ts state machine: deal, turns, claim resolution, settlement
434446 src/game/ctl.ts TableCtl — the surface the UI drives, local or networked
435447 src/game/wall.ts what is left of the square, and what still blocks a throw
436-src/net/session.ts the antics room: host duties, seats, intents, migration
448+src/net/room.ts the wire itself: join, reconnect, state, events
449+src/net/session.ts the room: host duties, seats, intents, migration
437450 src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay
438451 src/net/protocol.ts what crosses the wire, in one place
439452 src/net/handOrder.ts your own arrangement, kept on your own device
440-src/net/clock.ts worker-driven SDK clock, so a hidden host keeps hosting
441453 src/ui/Controller.tsx the phone at the party table: your tiles and the throw
442454 src/ui/OnlineLobby.tsx seats and the deal button, before an online game
443-scripts/antics-deploy.mjs dist/ → antics.gg, voice pack and all
455+server/rooms.ts the relay: rooms, host election, state fan-out
456+server/index.ts production: dist/ and the relay from one port
444457 src/table/physics.ts the discard pool: rectangles with weight, pure and testable
445458 src/table/geometry.ts the table measured — colliders, launch points, throw gate
446459 src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
⋯ 53 unchanged lines
modifiedTODO.md+7 −6
⋯ 22 unchanged lines
2323 flags plus a payment hook.
2424 ## Online (new)
2525
26-- [ ] a stable share link: deploy under an antics project (`npx antics-cli
27- login`, then `node scripts/antics-deploy.mjs --project <id>`) — keyless
28- links pin one build and keyless rooms die after 24h
26+- [ ] put it on a real domain: `npm run serve` behind a TLS reverse proxy
27+ (phones only get camera and fullscreen on https) — anvil, a VPS, a
28+ tailscale funnel, anything that terminates TLS
2929 - [ ] undo online: only the party screen has it, and only while it is the
30- antics host; online games have none at all
30+ room's host; online games have none at all
3131 - [ ] full-table online clients throw with the local gesture but others see
3232 the default seat-launch animation — could send the NetThrow for those too
33-- [ ] cheat-resistance: room state carries the wall and every hand; the antics
34- sim tier (paid) would keep secrets server-side
33+- [ ] cheat-resistance: room state carries the wall and every hand; the relay
34+ is ours now, so the engine could move into `server/rooms.ts` and keep
35+ secrets server-side
3536 - [ ] voice/sfx settings on the phone controller (it inherits defaults, has no
3637 toggle of its own)
3738
⋯ 7 unchanged lines
modifiedpackage-lock.json+34 −17
⋯ 7 unchanged lines
88 "name": "mahjong",
99 "version": "0.0.0",
1010 "dependencies": {
11- "antics-sdk": "^0.3.0",
1211 "react": "^19.2.8",
1312 "react-dom": "^19.2.8",
14- "uqr": "^0.1.3"
13+ "uqr": "^0.1.3",
14+ "ws": "^8.21.3"
1515 },
1616 "devDependencies": {
1717 "@types/node": "^24.13.3",
1818 "@types/react": "^19.2.17",
1919 "@types/react-dom": "^19.2.3",
20+ "@types/ws": "^8.18.1",
2021 "@vitejs/plugin-react": "^6.0.4",
2122 "oxlint": "^1.75.0",
2223 "typescript": "~6.0.2",
⋯ 690 unchanged lines
713714 "@types/react": "^19.2.0"
714715 }
715716 },
717+ "node_modules/@types/ws": {
718+ "version": "8.18.1",
719+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
720+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
721+ "dev": true,
722+ "license": "MIT",
723+ "dependencies": {
724+ "@types/node": "*"
725+ }
726+ },
716727 "node_modules/@vitejs/plugin-react": {
717728 "version": "6.0.5",
718729 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
⋯ 133 unchanged lines
852863 "url": "https://opencollective.com/vitest"
853864 }
854865 },
855- "node_modules/antics-protocol": {
856- "version": "0.2.0",
857- "resolved": "https://registry.npmjs.org/antics-protocol/-/antics-protocol-0.2.0.tgz",
858- "integrity": "sha512-9kFogFzYF99ajcxCB9hWv4IRw3sgO1dF1jbw6cK7sx9EVc+rUuUh2xGhqDzdWoajZz3AmmeBhjmhfTfg2Hlq/Q==",
859- "license": "SEE LICENSE IN LICENSE"
860- },
861- "node_modules/antics-sdk": {
862- "version": "0.3.0",
863- "resolved": "https://registry.npmjs.org/antics-sdk/-/antics-sdk-0.3.0.tgz",
864- "integrity": "sha512-GhgJP2HiJaZTH89MsUZyJsNiJCkslLy965BAAudCYpFGQStJMUu6Yrg4VB1famvccZYK4ghS2eKwFnCvRgWeXg==",
865- "license": "SEE LICENSE IN LICENSE",
866- "dependencies": {
867- "antics-protocol": "^0.1.0 || ^0.2.0"
868- }
869- },
870866 "node_modules/assertion-error": {
871867 "version": "2.0.1",
872868 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
⋯ 865 unchanged lines
17381734 "engines": {
17391735 "node": ">=8"
17401736 }
1737+ },
1738+ "node_modules/ws": {
1739+ "version": "8.21.3",
1740+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
1741+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
1742+ "license": "MIT",
1743+ "engines": {
1744+ "node": ">=10.0.0"
1745+ },
1746+ "peerDependencies": {
1747+ "bufferutil": "^4.0.1",
1748+ "utf-8-validate": ">=5.0.2"
1749+ },
1750+ "peerDependenciesMeta": {
1751+ "bufferutil": {
1752+ "optional": true
1753+ },
1754+ "utf-8-validate": {
1755+ "optional": true
1756+ }
1757+ }
17411758 }
17421759 }
17431760 }
modifiedpackage.json+4 −2
⋯ 7 unchanged lines
88 "build": "tsc -b && vite build",
99 "lint": "oxlint",
1010 "preview": "vite preview",
11+ "serve": "node server/index.ts",
1112 "test": "vitest run"
1213 },
1314 "dependencies": {
14- "antics-sdk": "^0.3.0",
1515 "react": "^19.2.8",
1616 "react-dom": "^19.2.8",
17- "uqr": "^0.1.3"
17+ "uqr": "^0.1.3",
18+ "ws": "^8.21.3"
1819 },
1920 "devDependencies": {
2021 "@types/node": "^24.13.3",
2122 "@types/react": "^19.2.17",
2223 "@types/react-dom": "^19.2.3",
24+ "@types/ws": "^8.18.1",
2325 "@vitejs/plugin-react": "^6.0.4",
2426 "oxlint": "^1.75.0",
2527 "typescript": "~6.0.2",
⋯ 4 unchanged lines
deletedscripts/antics-deploy.mjs+0 −101
1-#!/usr/bin/env node
2-// Deploy the built game to antics.gg as a multi-file bundle.
3-//
4-// npm run build && node scripts/antics-deploy.mjs # keyless: a fresh link each time
5-// node scripts/antics-deploy.mjs --project <proj_id> # under a project: stable /p/ share link
6-//
7-// A keyless deploy needs no account at all. Deploying under a project needs a
8-// token from `npx antics-cli login`, read from ANTICS_TOKEN or ~/.antics/token.
9-
10-import { readdir, readFile } from 'node:fs/promises';
11-import { homedir } from 'node:os';
12-import { join, relative, extname } from 'node:path';
13-
14-const HOST = process.env.ANTICS_HOST || 'https://antics.gg';
15-const DIST = new URL('../dist', import.meta.url).pathname;
16-
17-// Anything not in this list rides along as a data: URI.
18-const TEXT = new Set(['.html', '.js', '.css', '.svg', '.json', '.webmanifest', '.txt', '.map']);
19-const MIME = {
20- '.png': 'image/png',
21- '.jpg': 'image/jpeg',
22- '.webp': 'image/webp',
23- '.mp3': 'audio/mpeg',
24- '.woff2': 'font/woff2',
25- '.ico': 'image/x-icon',
26-};
27-
28-async function* walk(dir) {
29- for (const e of await readdir(dir, { withFileTypes: true })) {
30- const p = join(dir, e.name);
31- if (e.isDirectory()) yield* walk(p);
32- else yield p;
33- }
34-}
35-
36-async function collect() {
37- const files = {};
38- const voice = {};
39- let bytes = 0;
40- for await (const p of walk(DIST)) {
41- const rel = relative(DIST, p);
42- const ext = extname(rel);
43- const buf = await readFile(p);
44- bytes += buf.length;
45- // A bundle may hold at most 50 files and the voice pack alone is 57, so
46- // the clips ship as one JSON of base64 — sound.ts prefers it when present.
47- if (rel.startsWith('voice/') && ext === '.mp3') {
48- voice[rel.slice('voice/'.length, -'.mp3'.length)] = buf.toString('base64');
49- continue;
50- }
51- if (rel === 'voice/manifest.json') continue;
52- files[rel] = TEXT.has(ext)
53- ? buf.toString('utf8')
54- : `data:${MIME[ext] ?? 'application/octet-stream'};base64,${buf.toString('base64')}`;
55- }
56- if (Object.keys(voice).length) files['voice/pack.json'] = JSON.stringify(voice);
57- return { files, bytes };
58-}
59-
60-async function token() {
61- if (process.env.ANTICS_TOKEN) return process.env.ANTICS_TOKEN;
62- try {
63- return (await readFile(join(homedir(), '.antics', 'token'), 'utf8')).trim();
64- } catch {
65- return null;
66- }
67-}
68-
69-const args = process.argv.slice(2);
70-const projectAt = args.indexOf('--project');
71-const project = projectAt >= 0 ? args[projectAt + 1] : null;
72-
73-const { files, bytes } = await collect();
74-console.log(`deploying ${Object.keys(files).length} files, ${(bytes / 1024 / 1024).toFixed(1)} MB from dist/`);
75-
76-const body = { files };
77-const headers = { 'content-type': 'application/json' };
78-if (project) {
79- body.project = project;
80- const t = await token();
81- if (!t) {
82- console.error('a --project deploy needs a token: run `npx antics-cli login`, or set ANTICS_TOKEN');
83- process.exit(1);
84- }
85- headers.authorization = `Bearer ${t}`;
86-}
87-
88-const res = await fetch(`${HOST}/api/deploy`, {
89- method: 'POST',
90- headers,
91- body: JSON.stringify(body),
92-});
93-const out = await res.json().catch(() => ({}));
94-if (!res.ok) {
95- console.error(`deploy failed: ${res.status}`, out);
96- process.exit(1);
97-}
98-console.log('deployed.');
99-if (out.playUrl) console.log(` play (this build): ${out.playUrl}`);
100-if (out.shareUrl) console.log(` share (follows updates): ${out.shareUrl}`);
101-if (out.hash) console.log(` hash: ${out.hash}`);
addedserver/index.ts+69 −0
1+import { createServer } from 'node:http';
2+import { createReadStream } from 'node:fs';
3+import { stat } from 'node:fs/promises';
4+import { extname, join, resolve } from 'node:path';
5+import { fileURLToPath } from 'node:url';
6+import { attachRooms } from './rooms.ts';
7+
8+/**
9+ * The whole production stack in one process: the built game out of dist/, and
10+ * the room relay on /ws of the same origin. `npm run build && npm run serve`.
11+ * In development this file is not involved — vite serves the game and
12+ * vite.config.ts attaches the same relay to vite's own server.
13+ */
14+
15+const DIST = resolve(fileURLToPath(import.meta.url), '../../dist');
16+const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
17+const HOST = process.env.HOST || '0.0.0.0';
18+
19+const MIME: Record<string, string> = {
20+ '.html': 'text/html; charset=utf-8',
21+ '.js': 'text/javascript',
22+ '.css': 'text/css',
23+ '.svg': 'image/svg+xml',
24+ '.png': 'image/png',
25+ '.mp3': 'audio/mpeg',
26+ '.json': 'application/json',
27+ '.webmanifest': 'application/manifest+json',
28+ '.ico': 'image/x-icon',
29+ '.txt': 'text/plain; charset=utf-8',
30+ '.map': 'application/json',
31+ '.woff2': 'font/woff2',
32+};
33+
34+const server = createServer(async (req, res) => {
35+ const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
36+ const file = resolve(join(DIST, pathname === '/' ? 'index.html' : pathname));
37+ if (!file.startsWith(DIST)) {
38+ res.writeHead(403).end();
39+ return;
40+ }
41+ try {
42+ const s = await stat(file);
43+ if (!s.isFile()) throw new Error('not a file');
44+ const ext = extname(file);
45+ res.writeHead(200, {
46+ 'content-type': MIME[ext] ?? 'application/octet-stream',
47+ 'content-length': s.size,
48+ // Hashed assets may be kept forever; everything else asks again.
49+ 'cache-control': pathname.startsWith('/assets/')
50+ ? 'public, max-age=31536000, immutable'
51+ : 'no-cache',
52+ });
53+ createReadStream(file).pipe(res);
54+ } catch {
55+ res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
56+ }
57+});
58+
59+attachRooms(server);
60+// Upgrades to anything but /ws have no other takers here — hang up.
61+server.on('upgrade', (req, socket) => {
62+ if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/ws') socket.destroy();
63+});
64+
65+server.listen(PORT, HOST, async () => {
66+ const built = await stat(join(DIST, 'index.html')).catch(() => null);
67+ console.log(`mahjong on http://${HOST}:${PORT} — rooms on /ws`);
68+ if (!built) console.log('dist/ is empty — run `npm run build` first');
69+});
addedserver/net.test.mjs+95 −0
1+import { createServer } from 'node:http';
2+import { afterAll, beforeAll, expect, test } from 'vitest';
3+import { joinRoom } from '../src/net/room.ts';
4+import { attachRooms } from './rooms.ts';
5+
6+// The real client against the real relay, in one process. Plain .mjs on
7+// purpose: the client is typed for the browser and the relay for node, and
8+// neither tsconfig wants to swallow the other — vitest transforms both.
9+
10+let server;
11+let url;
12+const opened = [];
13+
14+beforeAll(async () => {
15+ server = createServer();
16+ attachRooms(server);
17+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
18+ url = `ws://127.0.0.1:${server.address().port}/ws`;
19+});
20+afterAll(async () => {
21+ for (const r of opened) r.leave();
22+ await new Promise((r) => server.close(r));
23+});
24+
25+const join = async (roomCode) => {
26+ const r = await joinRoom({ room: roomCode, server: url });
27+ opened.push(r);
28+ return r;
29+};
30+const next = (sub) => new Promise((r) => {
31+ const un = sub((...args) => {
32+ un();
33+ r(args);
34+ });
35+});
36+
37+test('a full little room: join, state, events, migration', async () => {
38+ const a = await join();
39+ expect(a.code).toMatch(/^[A-Z2-9]{6}$/);
40+ expect(a.isHost).toBe(true);
41+ expect(a.me.name).toBe('Guest 1');
42+
43+ const joinSeen = next((cb) => a.onJoin(cb));
44+ const b = await join(a.code);
45+ expect(b.code).toBe(a.code);
46+ expect(b.isHost).toBe(false);
47+ expect(b.players.length).toBe(2);
48+ expect((await joinSeen)[0].id).toBe(b.me.id);
49+
50+ // Host writes, mirror reads.
51+ const stateSeen = next((cb) => b.onState(cb));
52+ a.setState({ mode: 'party', game: { turn: 2 } });
53+ await stateSeen;
54+ expect(b.state.mode).toBe('party');
55+ expect(b.state.game).toEqual({ turn: 2 });
56+
57+ // Only the host writes.
58+ expect(() => b.setState({ evil: true })).toThrow();
59+
60+ // Events go the other way, stamped with the sender.
61+ const actSeen = next((cb) => a.on('act', (payload, from) => cb(payload, from)));
62+ b.send('act', { seat: 1 });
63+ const [payload, from] = await actSeen;
64+ expect(payload).toEqual({ seat: 1 });
65+ expect(from).toBe(b.me.id);
66+
67+ // The host leaves; the room passes to the next in line, state intact.
68+ const leaveSeen = next((cb) => b.onLeave(cb));
69+ const hostSeen = next((cb) => b.onHostChange(cb));
70+ a.leave();
71+ expect((await leaveSeen)[0].id).toBe(a.me.id);
72+ await hostSeen;
73+ expect(b.isHost).toBe(true);
74+ expect(b.state.mode).toBe('party');
75+ b.setState({ mode: 'party', n: 1 }); // and the crown is real
76+});
77+
78+test('a late joiner walks in on the state as it stands', async () => {
79+ const a = await join();
80+ a.setState({ mode: 'online', seats: [1, 2, 3, 4] });
81+ // The relay applies the (microtask-coalesced) write before answering a
82+ // later hello, both being one client's messages in order — but ours is a
83+ // second connection, so give the flush a beat.
84+ await new Promise((r) => setTimeout(r, 50));
85+ const b = await join(a.code);
86+ expect(b.state.mode).toBe('online');
87+ expect(b.state.seats).toEqual([1, 2, 3, 4]);
88+});
89+
90+test('an unknown code becomes a fresh room, not an error', async () => {
91+ const a = await join('REBORN');
92+ expect(a.code).toBe('REBORN');
93+ expect(a.isHost).toBe(true);
94+ expect(a.state).toEqual({});
95+});
addedserver/rooms.ts+232 −0
1+import type { IncomingMessage } from 'node:http';
2+import type { Duplex } from 'node:stream';
3+import { WebSocketServer, type WebSocket } from 'ws';
4+
5+/**
6+ * The room relay: everything the game needs from a server, which is very
7+ * little. Rooms hold members in join order; the earliest one still connected
8+ * is the host. The relay never reads the game — it keeps one bag of shared
9+ * state that only the host may write, fans out patches and events to everyone
10+ * else, and announces joins, leaves, and host changes. All the mahjong lives
11+ * in the clients (src/net/), exactly as it did on antics.
12+ *
13+ * Attachable to any http.Server — vite's dev server in development
14+ * (vite.config.ts), our own static server in production (server/index.ts) —
15+ * so the client can always reach it at wss://<same-origin>/ws.
16+ *
17+ * Identity is client-chosen: a device sends its own id (a UUID it keeps in
18+ * sessionStorage) and the relay takes it at its word. That is what lets a
19+ * reload or a wifi blip land back in the same seat — the seat map in shared
20+ * state is keyed on these ids. No auth; this is a home server for a table of
21+ * friends, and fairness at this table is social anyway (see README).
22+ */
23+
24+interface Member {
25+ id: string;
26+ name: string;
27+ ws: WebSocket;
28+}
29+
30+interface RoomRec {
31+ code: string;
32+ members: Map<string, Member>;
33+ /** Join order. The first id still present is the host. */
34+ order: string[];
35+ state: Record<string, unknown>;
36+ /** How many "Guest N" names this room has handed out. */
37+ guests: number;
38+}
39+
40+/** What a client may say. `hello` must come first; the rest need a room. */
41+type ClientMsg =
42+ | { t: 'hello'; id?: string; room?: string; name?: string }
43+ | { t: 'set'; patch?: Record<string, unknown> }
44+ | { t: 'ev'; type?: string; payload?: unknown };
45+
46+/** No 0/O/1/I/L: these codes get read aloud across a table. */
47+const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
48+const PING_MS = 30_000;
49+
50+function newCode(taken: (code: string) => boolean): string {
51+ for (;;) {
52+ let code = '';
53+ for (let i = 0; i < 6; i++)
54+ code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
55+ if (!taken(code)) return code;
56+ }
57+}
58+
59+/** Just the two events we hang off a server — http and http2 servers both
60+ * fit, which is what lets the relay ride vite's as easily as our own. */
61+export interface UpgradeServer {
62+ on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
63+ on(event: 'close', cb: () => void): unknown;
64+}
65+
66+export function attachRooms(server: UpgradeServer, path = '/ws'): void {
67+ const rooms = new Map<string, RoomRec>();
68+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
69+
70+ server.on('upgrade', (req, socket, head) => {
71+ let pathname: string;
72+ try {
73+ pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
74+ } catch {
75+ socket.destroy();
76+ return;
77+ }
78+ // Not ours (vite's HMR socket, say) — leave it for whoever else listens.
79+ if (pathname !== path) return;
80+ wss.handleUpgrade(req, socket, head, (ws) => handle(ws));
81+ });
82+
83+ // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell
84+ // off the wifi closes nothing; this is what finally vacates its seat.
85+ const alive = new WeakMap<WebSocket, boolean>();
86+ const sweep = setInterval(() => {
87+ for (const ws of wss.clients) {
88+ if (alive.get(ws) === false) {
89+ ws.terminate();
90+ continue;
91+ }
92+ alive.set(ws, false);
93+ ws.ping();
94+ }
95+ }, PING_MS);
96+ server.on('close', () => {
97+ clearInterval(sweep);
98+ wss.close();
99+ });
100+
101+ const send = (ws: WebSocket, msg: unknown) => {
102+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
103+ };
104+ const broadcast = (room: RoomRec, msg: unknown, except?: string) => {
105+ for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg);
106+ };
107+ const hostOf = (room: RoomRec) => room.order[0] ?? '';
108+ const roster = (room: RoomRec) =>
109+ room.order.map((id) => {
110+ const m = room.members.get(id)!;
111+ return { id: m.id, name: m.name };
112+ });
113+
114+ function handle(ws: WebSocket) {
115+ let room: RoomRec | null = null;
116+ let me: Member | null = null;
117+ alive.set(ws, true);
118+ ws.on('pong', () => alive.set(ws, true));
119+ ws.on('error', () => {});
120+
121+ ws.on('message', (data) => {
122+ let msg: ClientMsg;
123+ try {
124+ msg = JSON.parse(String(data));
125+ } catch {
126+ return send(ws, {
127+ t: 'err',
128+ code: 'BAD_MESSAGE',
129+ message: 'not JSON',
130+ hint: 'every frame is one JSON object',
131+ });
132+ }
133+
134+ if (msg.t === 'hello') {
135+ if (room) return; // one hello per connection
136+ if (typeof msg.id !== 'string' || !msg.id) {
137+ return send(ws, {
138+ t: 'err',
139+ code: 'BAD_MESSAGE',
140+ message: 'hello without an id',
141+ hint: 'send { t: "hello", id: <your uuid>, room?: <code> }',
142+ });
143+ }
144+ // Join by code, creating the room if it does not exist — a code
145+ // nobody knows is a room nobody made, and recreating on join is what
146+ // lets a table pick itself back up after the server restarts.
147+ const code =
148+ typeof msg.room === 'string' && msg.room
149+ ? msg.room.toUpperCase()
150+ : newCode((c) => rooms.has(c));
151+ let r = rooms.get(code);
152+ if (!r) {
153+ r = { code, members: new Map(), order: [], state: {}, guests: 0 };
154+ rooms.set(code, r);
155+ }
156+ room = r;
157+
158+ const existing = r.members.get(msg.id);
159+ if (existing) {
160+ // The same device again — a reconnect, or a zombie socket it gave
161+ // up on. The new socket takes over; the old one's close handler
162+ // sees it no longer speaks for the member and stays quiet.
163+ const old = existing.ws;
164+ existing.ws = ws;
165+ me = existing;
166+ if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced');
167+ } else {
168+ const name =
169+ typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`;
170+ me = { id: msg.id, name, ws };
171+ r.members.set(me.id, me);
172+ r.order.push(me.id);
173+ broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id);
174+ }
175+ return send(ws, {
176+ t: 'joined',
177+ code: r.code,
178+ self: { id: me.id, name: me.name },
179+ hostId: hostOf(r),
180+ players: roster(r),
181+ state: r.state,
182+ });
183+ }
184+
185+ if (!room || !me) {
186+ return send(ws, {
187+ t: 'err',
188+ code: 'NOT_JOINED',
189+ message: 'say hello first',
190+ hint: 'the first frame on a connection is { t: "hello", ... }',
191+ });
192+ }
193+
194+ switch (msg.t) {
195+ case 'set': {
196+ if (me.id !== hostOf(room)) {
197+ return send(ws, {
198+ t: 'err',
199+ code: 'NOT_HOST',
200+ message: 'only the host writes room state',
201+ hint: 'send an event and let the host do the writing',
202+ });
203+ }
204+ if (!msg.patch || typeof msg.patch !== 'object') return;
205+ Object.assign(room.state, msg.patch);
206+ broadcast(room, { t: 'set', patch: msg.patch }, me.id);
207+ return;
208+ }
209+ case 'ev': {
210+ if (typeof msg.type !== 'string') return;
211+ broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id);
212+ return;
213+ }
214+ }
215+ });
216+
217+ ws.on('close', () => {
218+ if (!room || !me) return;
219+ // A replaced socket no longer speaks for the member.
220+ if (room.members.get(me.id)?.ws !== ws) return;
221+ const wasHost = hostOf(room) === me.id;
222+ room.members.delete(me.id);
223+ room.order = room.order.filter((id) => id !== me!.id);
224+ if (room.members.size === 0) {
225+ rooms.delete(room.code);
226+ return;
227+ }
228+ broadcast(room, { t: 'leave', id: me.id });
229+ if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) });
230+ });
231+ }
232+}
modifiedsrc/App.tsx+3 −3
⋯ 27 unchanged lines
2828 const SOLO_SEATS = [false, true, true, true];
2929 const HOTSEAT_SEATS = [false, false, false, false];
3030
31-// One join per page, whoever asks first — the lobby button, or the ?room= the
32-// antics page hands us. A promise rather than a flag, so a second caller waits
31+// One join per page, whoever asks first — the lobby button, or the ?room= a
32+// shared link carries. A promise rather than a flag, so a second caller waits
3333 // on the same join instead of starting another.
3434 let joining: Promise<NetSession> | null = null;
3535 const joinOnce = () => (joining ??= NetSession.join());
⋯ 47 unchanged lines
8383 }
8484 };
8585
86- // Opened from a room link — the antics page passes ?room= down to us — so
86+ // Opened from a room link — ?room= in the address — so
8787 // everyone on that link shares one table. Join it before anyone taps
8888 // anything: presence is the first thing a shared room owes its visitors.
8989 const auto = useRef(false);
⋯ 329 unchanged lines
modifiedsrc/game/sound.ts+2 −3
⋯ 93 unchanged lines
9494 function fetchVoicePack(): Promise<void> {
9595 if (fetching) return fetching;
9696 fetching = (async () => {
97- // Deployed, the whole pack rides in one file — an antics bundle may hold
98- // at most 50 files, and the voice alone is more than that loose. In dev
99- // the loose mp3s are still there and the fallback below takes them.
97+ // A host that packed the whole voice into one file (voice/pack.json)
98+ // gets it in one request; the loose mp3s below are the normal path.
10099 const packed: Record<string, string> | null = await fetch(`${VOICE_DIR}pack.json`)
101100 .then((r) => (r.ok ? r.json() : null))
102101 .catch(() => null);
⋯ 303 unchanged lines
deletedsrc/net/clock.ts+0 −58
1-/**
2- * Keep the room alive while this tab is hidden.
3- *
4- * The antics SDK runs its loops — the state flush above all — off
5- * requestAnimationFrame, which a browser suspends the moment a tab stops being
6- * visible. For most games that is right: a hidden player is not playing. The
7- * party table is different — the common screen is the room's host, and a host
8- * that stops flushing takes the whole room with it: a phone's seat claim is
9- * processed but never announced, so the phone sits on "pick a seat" forever
10- * while the answer waits in a queue for the laptop to be looked at.
11- *
12- * The SDK provides for exactly this: anything on `globalThis.__anticsClock__`
13- * replaces its own timers, resolved when each loop starts. So the ticks come
14- * from a Web Worker instead — worker timers are not throttled in background
15- * tabs — and the host keeps hosting whether or not its tab is front.
16- *
17- * Installed before joinRoom, once. The game's own render loops are untouched:
18- * they use the real requestAnimationFrame and are happy to sleep with the tab.
19- */
20-export function installBackgroundClock() {
21- const g = globalThis as unknown as Record<string, unknown>;
22- if (g.__anticsClock__) return;
23-
24- let worker: Worker | null = null;
25- try {
26- const src = URL.createObjectURL(
27- new Blob(['setInterval(() => postMessage(0), 66);'], { type: 'text/javascript' }),
28- );
29- worker = new Worker(src);
30- URL.revokeObjectURL(src);
31- } catch {
32- // A sandbox that refuses workers keeps the SDK's own timers — no worse
33- // than before.
34- return;
35- }
36-
37- const callbacks = new Map<number, (t: number) => void>();
38- let nextId = 1;
39- worker.onmessage = () => {
40- if (callbacks.size === 0) return;
41- const now = performance.now();
42- const due = [...callbacks.values()];
43- callbacks.clear();
44- for (const cb of due) cb(now);
45- };
46-
47- g.__anticsClock__ = {
48- now: () => Date.now(),
49- requestAnimationFrame(cb: (t: number) => void): number {
50- const id = nextId++;
51- callbacks.set(id, cb);
52- return id;
53- },
54- cancelAnimationFrame(id: number) {
55- callbacks.delete(id);
56- },
57- };
58-}
modifiedsrc/net/protocol.ts+8 −9
⋯ 5 unchanged lines
66 /**
77 * What goes over the wire, in one place.
88 *
9- * The room is host-authoritative: exactly one client — whichever one antics
10- * says is the host — runs the real engine, and everything it decides is
9+ * The room is host-authoritative: exactly one client — whichever one the
10+ * relay says is the host — runs the real engine, and everything it decides is
1111 * published as plain `GameState` JSON under one key of the shared room state.
1212 * Everyone else renders that and asks for things by sending intents, which the
1313 * host validates against the seat map before playing them through the same
⋯ 9 unchanged lines
2323 * Who holds a seat. A list, not a single id: a seat is a hand, and nothing
2424 * stops two people huddling over one hand — a pair playing together from two
2525 * phones simply both hold it, and whoever acts first acts. The name outlives
26- * every connection — see the antics docs on leavers: record it where it
27- * arrives, the match outlives the connection.
26+ * every connection: record it where it arrives, the match outlives the
27+ * connection.
2828 */
2929 export interface SeatSlot {
30- /** antics player ids of everyone holding this hand. */
30+ /** Player ids of everyone holding this hand. */
3131 ids: string[];
3232 name: string;
3333 }
⋯ 59 unchanged lines
9393 }
9494 }
9595
96-/** Whether the page arrived with a room to join — i.e. we are on antics. */
96+/** The room in the page URL — a shared link, or our own join writing it back. */
9797 export function roomFromUrl(): string | null {
9898 try {
9999 return new URLSearchParams(window.location.search).get('room');
⋯ 7 unchanged lines
107107 * query. No seat — a phone that scans it lands on the seat picker, which is
108108 * also what lets a pair deliberately share a hand. (An old link with a `seat`
109109 * param still works: seatFromUrl above keeps reading it.) The page's own URL
110- * (the raw bundle on antics, the dev server at home) serves the game directly
111- * and keeps every param, which on a phone also means the whole screen belongs
112- * to the hand.
110+ * serves the game directly and keeps every param, which on a phone also means
111+ * the whole screen belongs to the hand.
113112 */
114113 export function roomLink(code: string): string {
115114 const u = new URL(window.location.href);
⋯ 4 unchanged lines
addedsrc/net/room.ts+308 −0
1+/**
2+ * The client side of our own room relay (server/rooms.ts), wearing the same
3+ * face as the sliver of the antics SDK the game used — code, me, isHost,
4+ * state/setState, send/on, the join/leave/host/error subscriptions — so
5+ * session.ts barely noticed the ejection. What the relay does not do for us
6+ * anymore we do here: reconnect with backoff, and rejoin as the same id.
7+ *
8+ * Identity is a UUID kept in sessionStorage: per tab, surviving reloads. The
9+ * shared seat map is keyed on it, so a reload or a wifi blip walks back into
10+ * its own seat. State writes coalesce per microtask; events go at once, so an
11+ * fx sent before a state change still arrives before it.
12+ */
13+
14+export interface PlayerInfo {
15+ id: string;
16+ name: string;
17+}
18+
19+export interface RoomError {
20+ code: string;
21+ message: string;
22+ hint: string;
23+}
24+
25+export interface JoinOptions {
26+ /** Room code to join; omit to create a fresh room. */
27+ room?: string;
28+ /** ws(s):// endpoint override (tests). Defaults to wss://<same-origin>/ws. */
29+ server?: string;
30+}
31+
32+type Unsub = () => void;
33+
34+type ServerMsg =
35+ | {
36+ t: 'joined';
37+ code: string;
38+ self: PlayerInfo;
39+ hostId: string;
40+ players: PlayerInfo[];
41+ state: Record<string, unknown>;
42+ }
43+ | { t: 'join'; player: PlayerInfo }
44+ | { t: 'leave'; id: string }
45+ | { t: 'host'; id: string }
46+ | { t: 'set'; patch: Record<string, unknown> }
47+ | { t: 'ev'; type: string; payload: unknown; from: string }
48+ | { t: 'err'; code: string; message: string; hint: string };
49+
50+const BASE_BACKOFF = 500;
51+const MAX_BACKOFF = 10_000;
52+const JOIN_TIMEOUT = 15_000;
53+
54+function endpoint(server?: string): string {
55+ if (server) return server;
56+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
57+ return `${proto}//${window.location.host}/ws`;
58+}
59+
60+function myId(): string {
61+ try {
62+ const key = 'mahjong-net-id';
63+ let id = sessionStorage.getItem(key);
64+ if (!id) {
65+ id = crypto.randomUUID();
66+ sessionStorage.setItem(key, id);
67+ }
68+ return id;
69+ } catch {
70+ return crypto.randomUUID();
71+ }
72+}
73+
74+export class Room {
75+ code = '';
76+ me: PlayerInfo = { id: myId(), name: '' };
77+ state: Record<string, unknown> = {};
78+ players: PlayerInfo[] = [];
79+
80+ private hostId = '';
81+ private url: string;
82+ private ws: WebSocket | null = null;
83+ private joined = false;
84+ private left = false;
85+ private attempts = 0;
86+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
87+ private pendingPatch: Record<string, unknown> | null = null;
88+ private flushQueued = false;
89+ private resolveJoin: ((r: Room) => void) | null = null;
90+ private rejectJoin: ((e: Error) => void) | null = null;
91+
92+ private stateCbs = new Set<(state: Record<string, unknown>, from: PlayerInfo | null) => void>();
93+ private joinCbs = new Set<(p: PlayerInfo) => void>();
94+ private leaveCbs = new Set<(p: PlayerInfo) => void>();
95+ private hostCbs = new Set<(host: PlayerInfo | null) => void>();
96+ private errorCbs = new Set<(e: RoomError) => void>();
97+ private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>();
98+
99+ private wantRoom: string | undefined;
100+
101+ private constructor(wantRoom: string | undefined, server?: string) {
102+ this.wantRoom = wantRoom;
103+ this.url = endpoint(server);
104+ }
105+
106+ /** Connect and resolve once the relay confirms the join with full state. */
107+ static join(opts: JoinOptions = {}): Promise<Room> {
108+ const room = new Room(opts.room, opts.server);
109+ return new Promise<Room>((resolve, reject) => {
110+ room.resolveJoin = resolve;
111+ room.rejectJoin = reject;
112+ const deadline = setTimeout(() => {
113+ room.leave();
114+ room.rejectJoin?.(new Error('連不上伺服器 could not reach the room server'));
115+ room.rejectJoin = null;
116+ }, JOIN_TIMEOUT);
117+ room.resolveJoin = (r) => {
118+ clearTimeout(deadline);
119+ resolve(r);
120+ };
121+ room.connect();
122+ });
123+ }
124+
125+ get isHost(): boolean {
126+ return this.hostId !== '' && this.hostId === this.me.id;
127+ }
128+ get host(): PlayerInfo | null {
129+ return this.player(this.hostId);
130+ }
131+ player(id: string): PlayerInfo | null {
132+ return this.players.find((p) => p.id === id) ?? null;
133+ }
134+
135+ /** Write shared state (host only): apply locally at once, flush coalesced. */
136+ setState(patch: Record<string, unknown>) {
137+ if (!this.isHost) {
138+ const e = new Error('only the host writes room state') as Error & RoomError;
139+ e.code = 'NOT_HOST';
140+ e.hint = 'send an event and let the host do the writing';
141+ throw e;
142+ }
143+ Object.assign(this.state, patch);
144+ this.pendingPatch = Object.assign(this.pendingPatch ?? {}, patch);
145+ if (!this.flushQueued) {
146+ this.flushQueued = true;
147+ queueMicrotask(() => this.flush());
148+ }
149+ }
150+
151+ /** Send an event to everyone else. Dropped while disconnected — an act lost
152+ * to a blip is retried by the person, not a queue. */
153+ send(type: string, payload?: unknown) {
154+ this.post({ t: 'ev', type, payload });
155+ }
156+
157+ leave() {
158+ this.left = true;
159+ if (this.retryTimer) clearTimeout(this.retryTimer);
160+ this.retryTimer = null;
161+ this.ws?.close(1000);
162+ this.ws = null;
163+ }
164+
165+ onState(cb: (state: Record<string, unknown>, from: PlayerInfo | null) => void): Unsub {
166+ return sub(this.stateCbs, cb);
167+ }
168+ onJoin(cb: (p: PlayerInfo) => void): Unsub {
169+ return sub(this.joinCbs, cb);
170+ }
171+ onLeave(cb: (p: PlayerInfo) => void): Unsub {
172+ return sub(this.leaveCbs, cb);
173+ }
174+ onHostChange(cb: (host: PlayerInfo | null) => void): Unsub {
175+ return sub(this.hostCbs, cb);
176+ }
177+ onError(cb: (e: RoomError) => void): Unsub {
178+ return sub(this.errorCbs, cb);
179+ }
180+ on(type: string, cb: (payload: unknown, from: string) => void): Unsub {
181+ let set = this.evCbs.get(type);
182+ if (!set) this.evCbs.set(type, (set = new Set()));
183+ return sub(set, cb);
184+ }
185+
186+ // ---- the wire ----------------------------------------------------------
187+ private connect() {
188+ if (this.left) return;
189+ const ws = new WebSocket(this.url);
190+ this.ws = ws;
191+ ws.onopen = () => {
192+ this.post({ t: 'hello', id: this.me.id, room: this.code || this.wantRoom });
193+ };
194+ ws.onmessage = (e) => {
195+ let msg: ServerMsg;
196+ try {
197+ msg = JSON.parse(String(e.data));
198+ } catch {
199+ return;
200+ }
201+ this.dispatch(msg);
202+ };
203+ ws.onclose = () => {
204+ if (this.ws !== ws || this.left) return;
205+ this.ws = null;
206+ this.emitError({
207+ code: 'DISCONNECTED',
208+ message: '連線中斷 connection lost',
209+ hint: 'reconnecting…',
210+ });
211+ const delay =
212+ Math.min(MAX_BACKOFF, BASE_BACKOFF * 2 ** this.attempts++) * (0.75 + Math.random() * 0.5);
213+ this.retryTimer = setTimeout(() => this.connect(), delay);
214+ };
215+ }
216+
217+ private post(msg: unknown) {
218+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
219+ }
220+
221+ private flush() {
222+ this.flushQueued = false;
223+ const patch = this.pendingPatch;
224+ if (!patch || this.ws?.readyState !== WebSocket.OPEN) return;
225+ this.pendingPatch = null;
226+ this.post({ t: 'set', patch });
227+ }
228+
229+ private dispatch(msg: ServerMsg) {
230+ switch (msg.t) {
231+ case 'joined':
232+ return this.applyJoined(msg);
233+ case 'join': {
234+ if (!this.players.some((p) => p.id === msg.player.id)) this.players.push(msg.player);
235+ for (const cb of this.joinCbs) cb(msg.player);
236+ return;
237+ }
238+ case 'leave': {
239+ const p = this.player(msg.id);
240+ this.players = this.players.filter((q) => q.id !== msg.id);
241+ if (p) for (const cb of this.leaveCbs) cb(p);
242+ return;
243+ }
244+ case 'host': {
245+ this.hostId = msg.id;
246+ for (const cb of this.hostCbs) cb(this.host);
247+ return;
248+ }
249+ case 'set': {
250+ Object.assign(this.state, msg.patch);
251+ for (const cb of this.stateCbs) cb(this.state, null);
252+ return;
253+ }
254+ case 'ev': {
255+ const set = this.evCbs.get(msg.type);
256+ if (set) for (const cb of set) cb(msg.payload, msg.from);
257+ return;
258+ }
259+ case 'err':
260+ return this.emitError(msg);
261+ }
262+ }
263+
264+ private applyJoined(msg: Extract<ServerMsg, { t: 'joined' }>) {
265+ const first = !this.joined;
266+ this.joined = true;
267+ this.attempts = 0;
268+ this.pendingPatch = null; // whatever we meant to say, the room has moved on
269+ this.code = msg.code;
270+ this.me = msg.self;
271+ const before = this.players;
272+ this.players = msg.players;
273+ const prevHost = this.hostId;
274+ this.hostId = msg.hostId;
275+ this.state = msg.state ?? {};
276+ if (first) {
277+ // The game's listeners are registered after join() resolves; it reads
278+ // the initial roster and state directly rather than being told.
279+ this.resolveJoin?.(this);
280+ this.resolveJoin = null;
281+ return;
282+ }
283+ // A resume: the room went on without us. Say everything that changed, in
284+ // the order the game expects it — state first (a host's leave handler
285+ // rewrites seats, and should rewrite the current ones).
286+ for (const cb of this.stateCbs) cb(this.state, null);
287+ for (const p of before)
288+ if (!this.players.some((q) => q.id === p.id)) for (const cb of this.leaveCbs) cb(p);
289+ for (const p of this.players)
290+ if (p.id !== this.me.id && !before.some((q) => q.id === p.id))
291+ for (const cb of this.joinCbs) cb(p);
292+ if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host);
293+ }
294+
295+ private emitError(e: RoomError) {
296+ if (this.errorCbs.size === 0) console.warn(`[room] ${e.code}: ${e.message} — ${e.hint}`);
297+ for (const cb of this.errorCbs) cb(e);
298+ }
299+}
300+
301+function sub<T>(set: Set<T>, cb: T): Unsub {
302+ set.add(cb);
303+ return () => {
304+ set.delete(cb);
305+ };
306+}
307+
308+export const joinRoom = (opts: JoinOptions = {}) => Room.join(opts);
modifiedsrc/net/session.ts+21 −15
1-import { joinRoom, type Room } from 'antics-sdk';
21 import { AutoPlay } from '../game/autoplay';
3-import { installBackgroundClock } from './clock';
42 import type { NetThrow } from '../game/ctl';
53 import type { SoundEvent } from '../game/engine';
64 import type { Tile } from '../game/tiles';
⋯ 7 unchanged lines
1412 type FxMsg,
1513 type NetMode,
1614 type SeatMsg,
15+ roomFromUrl,
1716 type SeatSlot,
1817 } from './protocol';
18+import { joinRoom, type Room } from './room';
1919 import { NetTable } from './table';
2020
2121 const SEATS: SeatId[] = [0, 1, 2, 3];
⋯ 1 unchanged line
2323 /**
2424 * One device's connection to a room, and everything that follows from it.
2525 *
26- * antics elects a host, and the host runs the table: the real engine, the
27- * computer players, the answering of everyone else's intents. Everybody else
28- * mirrors the state the host publishes. If the host leaves, antics picks a new
29- * one, whose mirror is already current — it starts its own engine from what it
30- * was just watching and the game carries on.
26+ * The relay elects a host — the earliest joiner still connected — and the host
27+ * runs the table: the real engine, the computer players, the answering of
28+ * everyone else's intents. Everybody else mirrors the state the host
29+ * publishes. If the host leaves, the relay picks a new one, whose mirror is
30+ * already current — it starts its own engine from what it was just watching
31+ * and the game carries on.
3132 *
3233 * `role` is this device's, not the room's: the laptop that starts a party game
3334 * is the `screen` everyone throws at; every other device is a `player`.
⋯ 30 unchanged lines
6465 const u = this.unsubs;
6566 u.push(
6667 room.onState(() => {
68+ // State flowing means the connection is back, whatever it said before.
69+ this.err = null;
6770 this.table.adopt(this.sharedGame());
6871 this.syncAutoplay();
6972 this.bump();
⋯ 43 unchanged lines
113116
114117 /** Join the room in the page URL, or create a fresh one. */
115118 static async join(): Promise<NetSession> {
116- // Before the SDK starts any loop: this device may end up hosting the
117- // room, and a host must keep answering with its tab in the background.
118- installBackgroundClock();
119- // Deployed on antics the SDK's same-origin detection is right; served from
120- // anywhere else — the dev server, most of all — it would knock on a host
121- // that has no room server behind it and hang, so name the real one.
122- const onAntics = /(^|\.)antics\.gg$/.test(window.location.hostname);
123- const room = await joinRoom(onAntics ? {} : { server: 'https://antics.gg' });
119+ const room = await joinRoom({ room: roomFromUrl() ?? undefined });
120+ // The room code goes into the address bar: the page's own URL becomes the
121+ // invite link, and a reload walks back into the same room — and, with the
122+ // id this device keeps, the same seat.
123+ try {
124+ const u = new URL(window.location.href);
125+ u.searchParams.set('room', room.code);
126+ window.history.replaceState(null, '', u);
127+ } catch {
128+ // A sandbox that refuses history rewrites still gets to play.
129+ }
124130 return new NetSession(room);
125131 }
126132
⋯ 258 unchanged lines
modifiedtsconfig.node.json+1 −1
⋯ 18 unchanged lines
1919 "erasableSyntaxOnly": true,
2020 "noFallthroughCasesInSwitch": true
2121 },
22- "include": ["vite.config.ts"]
22+ "include": ["vite.config.ts", "server"]
2323 }
modifiedvite.config.ts+18 −4
1-import { defineConfig } from 'vite'
1+import { defineConfig, type Plugin } from 'vite'
22 import react from '@vitejs/plugin-react'
3+import { attachRooms } from './server/rooms.ts'
4+
5+// The room relay rides vite's own http server, so `npm run dev` (and
6+// `npm run preview`) is the whole stack — the client finds it at /ws on the
7+// same origin, exactly where server/index.ts puts it in production.
8+const roomRelay = (): Plugin => ({
9+ name: 'room-relay',
10+ configureServer(s) {
11+ if (s.httpServer) attachRooms(s.httpServer)
12+ },
13+ configurePreviewServer(s) {
14+ if (s.httpServer) attachRooms(s.httpServer)
15+ },
16+})
317
418 // https://vite.dev/config/
519 export default defineConfig({
6- plugins: [react()],
7- // Served from wherever antics puts the bundle, not from a domain root — so
8- // every built URL has to be relative to the page.
20+ plugins: [react(), roomRelay()],
21+ // Served happily from under any path, not just a domain root — so every
22+ // built URL has to be relative to the page.
923 base: './',
1024 // portless hands us PORT/HOST; vite doesn't read them on its own
1125 server: {
⋯ 4 unchanged lines