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/** The seat in the QR's link, if the hosting page passed it through to us. */
123export function seatFromUrl(): SeatId | null {
124 try {
125 const raw = new URLSearchParams(window.location.search).get('seat');
126 if (raw === null) return null;
127 const n = Number(raw);
128 return n === 0 || n === 1 || n === 2 || n === 3 ? (n as SeatId) : null;
129 } catch {
130 return null;
131 }
132}
133
134/** The room in the page URL — a shared link, or our own join writing it back. */
135export function roomFromUrl(): string | null {
136 try {
137 return new URLSearchParams(window.location.search).get('room');
138 } catch {
139 return null;
140 }
141}
142
143/**
144 * The link a QR carries: this very page, with the room in the query.
145 *
146 * Without a seat it is the table's own — a phone that scans it lands on the
147 * seat picker, which is also what lets a pair deliberately share a hand. With
148 * one it is a particular hand's, the QR inside that seat's own gear, and
149 * scanning it takes that hand off the shared screen and onto the phone without
150 * anybody having to say which chair they are in.
151 *
152 * The page's own URL serves the game directly and keeps every param, which on
153 * a phone also means the whole screen belongs to the hand.
154 *
155 * `base` is the server's public face (`room.publicBase` — its rsgrok
156 * tunnel): a link has to be an address the phone can reach, which the host
157 * screen's own `localhost` is not. The path survives the swap; the tunnel
158 * fronts the same server.
159 */
160export function roomLink(code: string, base?: string, seat?: SeatId): string {
161 const u = new URL(window.location.href);
162 if (base) {
163 try {
164 const b = new URL(base);
165 u.protocol = b.protocol;
166 u.host = b.host;
167 } catch {
168 // A malformed base loses to a working local link.
169 }
170 }
171 u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`;
172 u.hash = '';
173 return u.toString();
174}