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
19/** The two ways a room can be playing. */
20export type NetMode = 'online' | 'party';
21
22/**
23 * Who holds a seat. A list, not a single id: a seat is a hand, and nothing
24 * stops two people huddling over one hand — a pair playing together from two
25 * phones simply both hold it, and whoever acts first acts. The name outlives
26 * every connection: record it where it arrives, the match outlives the
27 * connection.
28 */
29export interface SeatSlot {
30 /** Player ids of everyone holding this hand. */
31 ids: string[];
32 name: string;
33}
34
35/** The shared room state, as the host writes it. */
36export interface SharedKeys {
37 mode: NetMode;
38 seats: SeatSlot[];
39 /** The whole engine state. null while the room is still in its lobby. */
40 game: GameState | null;
41 /**
42 * Which seats have a device on them that will say their calls out loud —
43 * a phone in somebody's hand, with sound and 報牌 on and the hardware awake.
44 *
45 * The table reads this and shuts up for those seats: at a party table all
46 * five devices are in the same room, so the 碰 should come out of the hand
47 * of the person calling it and not out of the laptop in the middle. Held by
48 * the host, which is the only thing that hears every phone.
49 */
50 voices: boolean[];
51}
52
53/** Something a player asks the host to play for their seat. */
54export type Act =
55 | { kind: 'discard'; tile: Tile; thrown?: NetThrow }
56 | { kind: 'respond'; claim: Claim | 'pass' }
57 | { kind: 'resolveNow' }
58 | { kind: 'ankong'; tile: Tile }
59 | { kind: 'addkong'; tile: Tile }
60 | { kind: 'selfDraw' }
61 | { kind: 'nextHand' }
62 | { kind: 'newGame' };
63
64export interface ActMsg {
65 seat: SeatId;
66 act: Act;
67}
68
69/** Ask the host for a seat. null means "any free one". */
70export interface SeatMsg {
71 seat: SeatId | null;
72}
73
74/**
75 * Host to everyone else: things worth seeing or hearing that are not state — a
76 * sound cue, or the throw a discard arrived with, so every table can show the
77 * tile flying in from that player's edge.
78 */
79export type FxMsg =
80 | { fx: 'sfx'; kind: string; tile?: Tile; seat?: SeatId }
81 | { fx: 'throw'; seat: SeatId; t: NetThrow };
82
83/**
84 * A device telling the host whether it can talk. Sent whenever the answer
85 * changes — a mute, a 報牌 toggle, a phone waking its audio on the first tap,
86 * a tab going to sleep — and once on arrival.
87 */
88export interface VoiceMsg {
89 ready: boolean;
90}
91
92export const EV_ACT = 'act';
93export const EV_SEAT = 'seat';
94export const EV_FX = 'fx';
95export const EV_VOICE = 'voice';
96
97export const EMPTY_SEATS: SeatSlot[] = [
98 { ids: [], name: '' },
99 { ids: [], name: '' },
100 { ids: [], name: '' },
101 { ids: [], name: '' },
102];
103
104/** The seat in the QR's link, if the hosting page passed it through to us. */
105export function seatFromUrl(): SeatId | null {
106 try {
107 const raw = new URLSearchParams(window.location.search).get('seat');
108 if (raw === null) return null;
109 const n = Number(raw);
110 return n === 0 || n === 1 || n === 2 || n === 3 ? (n as SeatId) : null;
111 } catch {
112 return null;
113 }
114}
115
116/** The room in the page URL — a shared link, or our own join writing it back. */
117export function roomFromUrl(): string | null {
118 try {
119 return new URLSearchParams(window.location.search).get('room');
120 } catch {
121 return null;
122 }
123}
124
125/**
126 * The link the table's QR carries: this very page, with just the room in the
127 * query. No seat — a phone that scans it lands on the seat picker, which is
128 * also what lets a pair deliberately share a hand. (An old link with a `seat`
129 * param still works: seatFromUrl above keeps reading it.) The page's own URL
130 * serves the game directly and keeps every param, which on a phone also means
131 * the whole screen belongs to the hand.
132 *
133 * `base` is the server's public face (`room.publicBase` — its rsgrok
134 * tunnel): a link has to be an address the phone can reach, which the host
135 * screen's own `localhost` is not. The path survives the swap; the tunnel
136 * fronts the same server.
137 */
138export function roomLink(code: string, base?: string): string {
139 const u = new URL(window.location.href);
140 if (base) {
141 try {
142 const b = new URL(base);
143 u.protocol = b.protocol;
144 u.host = b.host;
145 } catch {
146 // A malformed base loses to a working local link.
147 }
148 }
149 u.search = `?room=${code}`;
150 u.hash = '';
151 return u.toString();
152}