anvilsign in

collin/mahjong

1import type { Claim, GameState } from '../game/engine';
2import type { NetThrow } from '../game/ctl';
3import type { Tile } from '../game/tiles';
4import type { SeatId } from '../game/types';
5
6/**
7 * What goes over the wire, in one place.
8 *
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
11 * published as plain `GameState` JSON under one key of the shared room state.
12 * Everyone else renders that and asks for things by sending intents, which the
13 * host validates against the seat map before playing them through the same
14 * engine methods a button would have called. The engine itself never knows any
15 * of this is happening — the same trick `autoplay.ts` plays, stretched over a
16 * network.
17 *
18 * There is one kind of game. A room is not opened until somebody wants a
19 * phone in it, and what it carries after that is only ever the same four
20 * questions: who is holding each hand, where the hands nobody holds are being
21 * played, and what the table looks like. Solo, four round a laptop and four in
22 * four cities are all arrangements of those, not modes — see `App.tsx`.
23 */
24
25/**
26 * Who holds a seat. A list, not a single id: a seat is a hand, and nothing
27 * stops two people huddling over one hand — a pair playing together from two
28 * phones simply both hold it, and whoever acts first acts. The name outlives
29 * every connection: record it where it arrives, the match outlives the
30 * connection.
31 */
32export interface SeatSlot {
33 /** Player ids of everyone holding this hand. */
34 ids: string[];
35 name: string;
36}
37
38/** The shared room state, as the host writes it. */
39export interface SharedKeys {
40 seats: SeatSlot[];
41 /**
42 * The device the table is being played at: the one that opened the room, and
43 * the one that goes on playing every seat no phone has taken. '' once it has
44 * left, which is what tells the room those seats have nobody behind them and
45 * should go to the computer — see `armStranded` in session.ts.
46 *
47 * It is not a mode. A laptop with four people round it and a phone with one
48 * are the same thing here: whichever device the game was started on is where
49 * the unclaimed hands are played, and every other device holds a hand.
50 */
51 screen: string;
52 /** The whole engine state. Never null in practice: a room is opened around a
53 * table that is already being played at. */
54 game: GameState | null;
55 /**
56 * Which seats have a device on them that will say their calls out loud —
57 * a phone in somebody's hand, with sound and 報牌 on and the hardware awake.
58 *
59 * Only a phone claims it — the device is small enough to be in a hand, and
60 * a hand is in the room. The screen reads this and shuts up for those seats,
61 * so the 碰 comes out of the hand of the person calling it and not out of
62 * the laptop in the middle. Held by the host, which is the only thing that
63 * hears every device.
64 */
65 voices: boolean[];
66}
67
68/** Something a player asks the host to play for their seat. */
69export type Act =
70 | { kind: 'discard'; tile: Tile; thrown?: NetThrow }
71 | { kind: 'respond'; claim: Claim | 'pass' }
72 | { kind: 'resolveNow' }
73 | { kind: 'ankong'; tile: Tile }
74 | { kind: 'addkong'; tile: Tile }
75 | { kind: 'selfDraw' }
76 | { kind: 'nextHand' }
77 | { kind: 'newGame' }
78 /** Give this hand to the computer, or take it back off it. Only ever about a
79 * hand nobody is holding — see `handleAct`. */
80 | { kind: 'bot'; on: boolean };
81
82export interface ActMsg {
83 seat: SeatId;
84 act: Act;
85}
86
87/** Ask the host for a seat. null means "any free one". */
88export interface SeatMsg {
89 seat: SeatId | null;
90}
91
92/**
93 * Host to everyone else: things worth seeing or hearing that are not state — a
94 * sound cue, or the throw a discard arrived with, so every table can show the
95 * tile flying in from that player's edge.
96 */
97export type FxMsg =
98 | { fx: 'sfx'; kind: string; tile?: Tile; seat?: SeatId }
99 | { fx: 'throw'; seat: SeatId; t: NetThrow };
100
101/**
102 * A device telling the host whether it can talk. Sent whenever the answer
103 * changes — a mute, a 報牌 toggle, a phone waking its audio on the first tap,
104 * a tab going to sleep — and once on arrival.
105 */
106export interface VoiceMsg {
107 ready: boolean;
108}
109
110export const EV_ACT = 'act';
111export const EV_SEAT = 'seat';
112export const EV_FX = 'fx';
113export const EV_VOICE = 'voice';
114
115export const EMPTY_SEATS: SeatSlot[] = [
116 { ids: [], name: '' },
117 { ids: [], name: '' },
118 { ids: [], name: '' },
119 { ids: [], name: '' },
120];
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 */
153const 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. */
156function 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 */
172function 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. */
183export function roomPath(code: string, seat: SeatId | null): string {
184 return `${appBase()}${code}${seat === null ? '' : `-${seat}`}`;
185}
186
187/** The seat in the QR's link, if the hosting page passed it through to us. */
188export function seatFromUrl(): SeatId | null {
189 const path = pathRoom();
190 if (path?.seat !== null && path?.seat !== undefined) return path.seat;
191 try {
192 const raw = new URLSearchParams(window.location.search).get('seat');
193 if (raw === null) return null;
194 const n = Number(raw);
195 return n === 0 || n === 1 || n === 2 || n === 3 ? (n as SeatId) : null;
196 } catch {
197 return null;
198 }
199}
200
201/** The room in the page URL — a shared link, or our own join writing it back. */
202export function roomFromUrl(): string | null {
203 const path = pathRoom();
204 if (path) return path.code;
205 try {
206 return new URLSearchParams(window.location.search).get('room');
207 } catch {
208 return null;
209 }
210}
211
212/**
213 * The link a QR carries: this very page, with the room in the query.
214 *
215 * Without a seat it is the table's own — a phone that scans it lands on the
216 * seat picker, which is also what lets a pair deliberately share a hand. With
217 * one it is a particular hand's, the QR inside that seat's own gear, and
218 * scanning it takes that hand off the shared screen and onto the phone without
219 * anybody having to say which chair they are in.
220 *
221 * The page's own URL serves the game directly and keeps every param, which on
222 * a phone also means the whole screen belongs to the hand.
223 *
224 * `base` is the server's public face (`room.publicBase` — its rsgrok
225 * tunnel): a link has to be an address the phone can reach, which the host
226 * screen's own `localhost` is not. The path survives the swap; the tunnel
227 * fronts the same server.
228 */
229export function roomLink(code: string, base?: string, seat?: SeatId): string {
230 const u = new URL(window.location.href);
231 if (base) {
232 try {
233 const b = new URL(base);
234 u.protocol = b.protocol;
235 u.host = b.host;
236 } catch {
237 // A malformed base loses to a working local link.
238 }
239 }
240 u.pathname = `${appBase()}${code}${seat === undefined ? '' : `-${seat}`}`;
241 u.search = '';
242 u.hash = '';
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();
248}