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
43/** Something a player asks the host to play for their seat. */
44export type Act =
45 | { kind: 'discard'; tile: Tile; thrown?: NetThrow }
46 | { kind: 'respond'; claim: Claim | 'pass' }
47 | { kind: 'resolveNow' }
48 | { kind: 'ankong'; tile: Tile }
49 | { kind: 'addkong'; tile: Tile }
50 | { kind: 'selfDraw' }
51 | { kind: 'nextHand' }
52 | { kind: 'newGame' };
53
54export interface ActMsg {
55 seat: SeatId;
56 act: Act;
57}
58
59/** Ask the host for a seat. null means "any free one". */
60export interface SeatMsg {
61 seat: SeatId | null;
62}
63
64/**
65 * Host to everyone else: things worth seeing or hearing that are not state — a
66 * sound cue, or the throw a discard arrived with, so every table can show the
67 * tile flying in from that player's edge.
68 */
69export type FxMsg =
70 | { fx: 'sfx'; kind: string; tile?: Tile }
71 | { fx: 'throw'; seat: SeatId; t: NetThrow };
72
73export const EV_ACT = 'act';
74export const EV_SEAT = 'seat';
75export const EV_FX = 'fx';
76
77export const EMPTY_SEATS: SeatSlot[] = [
78 { ids: [], name: '' },
79 { ids: [], name: '' },
80 { ids: [], name: '' },
81 { ids: [], name: '' },
82];
83
84/** The seat in the QR's link, if the hosting page passed it through to us. */
85export function seatFromUrl(): SeatId | null {
86 try {
87 const raw = new URLSearchParams(window.location.search).get('seat');
88 if (raw === null) return null;
89 const n = Number(raw);
90 return n === 0 || n === 1 || n === 2 || n === 3 ? (n as SeatId) : null;
91 } catch {
92 return null;
93 }
94}
95
96/** The room in the page URL — a shared link, or our own join writing it back. */
97export function roomFromUrl(): string | null {
98 try {
99 return new URLSearchParams(window.location.search).get('room');
100 } catch {
101 return null;
102 }
103}
104
105/**
106 * The link the table's QR carries: this very page, with just the room in the
107 * query. No seat — a phone that scans it lands on the seat picker, which is
108 * also what lets a pair deliberately share a hand. (An old link with a `seat`
109 * param still works: seatFromUrl above keeps reading it.) The page's own URL
110 * serves the game directly and keeps every param, which on a phone also means
111 * the whole screen belongs to the hand.
112 *
113 * `base` is the server's public face (`room.publicBase` — its rsgrok
114 * tunnel): a link has to be an address the phone can reach, which the host
115 * screen's own `localhost` is not. The path survives the swap; the tunnel
116 * fronts the same server.
117 */
118export function roomLink(code: string, base?: string): string {
119 const u = new URL(window.location.href);
120 if (base) {
121 try {
122 const b = new URL(base);
123 u.protocol = b.protocol;
124 u.host = b.host;
125 } catch {
126 // A malformed base loses to a working local link.
127 }
128 }
129 u.search = `?room=${code}`;
130 u.hash = '';
131 return u.toString();
132}