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 antics
10 * 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/** Who holds a seat. The name outlives the connection — see the antics docs on
23 * leavers: record it where it arrives, the match outlives the connection. */
24export interface SeatSlot {
25 /** antics player id, or null while nobody holds it. */
26 id: string | null;
27 name: string;
28}
29
30/** The shared room state, as the host writes it. */
31export interface SharedKeys {
32 mode: NetMode;
33 seats: SeatSlot[];
34 /** The whole engine state. null while the room is still in its lobby. */
35 game: GameState | null;
36}
37
38/** Something a player asks the host to play for their seat. */
39export type Act =
40 | { kind: 'discard'; tile: Tile; thrown?: NetThrow }
41 | { kind: 'respond'; claim: Claim | 'pass' }
42 | { kind: 'resolveNow' }
43 | { kind: 'ankong'; tile: Tile }
44 | { kind: 'addkong'; tile: Tile }
45 | { kind: 'selfDraw' }
46 | { kind: 'nextHand' }
47 | { kind: 'newGame' };
48
49export interface ActMsg {
50 seat: SeatId;
51 act: Act;
52}
53
54/** Ask the host for a seat. null means "any free one". */
55export interface SeatMsg {
56 seat: SeatId | null;
57}
58
59/**
60 * Host to everyone else: things worth seeing or hearing that are not state — a
61 * sound cue, or the throw a discard arrived with, so every table can show the
62 * tile flying in from that player's edge.
63 */
64export type FxMsg =
65 | { fx: 'sfx'; kind: string; tile?: Tile }
66 | { fx: 'throw'; seat: SeatId; t: NetThrow };
67
68export const EV_ACT = 'act';
69export const EV_SEAT = 'seat';
70export const EV_FX = 'fx';
71
72export const EMPTY_SEATS: SeatSlot[] = [
73 { id: null, name: '' },
74 { id: null, name: '' },
75 { id: null, name: '' },
76 { id: null, name: '' },
77];
78
79/** The seat in the QR's link, if the hosting page passed it through to us. */
80export function seatFromUrl(): SeatId | null {
81 try {
82 const raw = new URLSearchParams(window.location.search).get('seat');
83 if (raw === null) return null;
84 const n = Number(raw);
85 return n === 0 || n === 1 || n === 2 || n === 3 ? (n as SeatId) : null;
86 } catch {
87 return null;
88 }
89}
90
91/** Whether the page arrived with a room to join — i.e. we are on antics. */
92export function roomFromUrl(): string | null {
93 try {
94 return new URLSearchParams(window.location.search).get('room');
95 } catch {
96 return null;
97 }
98}
99
100/** The room's invite link with the seat stitched in, for that seat's QR. */
101export const seatLink = (link: string, seat: SeatId) =>
102 `${link}${link.includes('?') ? '&' : '?'}seat=${seat}`;