| 1 | import type { Claim, GameState } from '../game/engine'; |
| 2 | import type { NetThrow } from '../game/ctl'; |
| 3 | import type { Tile } from '../game/tiles'; |
| 4 | import 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. */ |
| 20 | export 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 | */ |
| 29 | export 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. */ |
| 36 | export 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. */ |
| 54 | export 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 | |
| 64 | export interface ActMsg { |
| 65 | seat: SeatId; |
| 66 | act: Act; |
| 67 | } |
| 68 | |
| 69 | /** Ask the host for a seat. null means "any free one". */ |
| 70 | export 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 | */ |
| 79 | export 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 | */ |
| 88 | export interface VoiceMsg { |
| 89 | ready: boolean; |
| 90 | } |
| 91 | |
| 92 | export const EV_ACT = 'act'; |
| 93 | export const EV_SEAT = 'seat'; |
| 94 | export const EV_FX = 'fx'; |
| 95 | export const EV_VOICE = 'voice'; |
| 96 | |
| 97 | export 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. */ |
| 105 | export 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. */ |
| 117 | export 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 a QR carries: this very page, with the room in the query. |
| 127 | * |
| 128 | * Without a seat it is the table's own — a phone that scans it lands on the |
| 129 | * seat picker, which is also what lets a pair deliberately share a hand. With |
| 130 | * one it is a particular hand's, the QR inside that seat's own gear, and |
| 131 | * scanning it takes that hand off the shared screen and onto the phone without |
| 132 | * anybody having to say which chair they are in. |
| 133 | * |
| 134 | * The page's own URL serves the game directly and keeps every param, which on |
| 135 | * a phone also means the whole screen belongs to the hand. |
| 136 | * |
| 137 | * `base` is the server's public face (`room.publicBase` — its rsgrok |
| 138 | * tunnel): a link has to be an address the phone can reach, which the host |
| 139 | * screen's own `localhost` is not. The path survives the swap; the tunnel |
| 140 | * fronts the same server. |
| 141 | */ |
| 142 | export function roomLink(code: string, base?: string, seat?: SeatId): string { |
| 143 | const u = new URL(window.location.href); |
| 144 | if (base) { |
| 145 | try { |
| 146 | const b = new URL(base); |
| 147 | u.protocol = b.protocol; |
| 148 | u.host = b.host; |
| 149 | } catch { |
| 150 | // A malformed base loses to a working local link. |
| 151 | } |
| 152 | } |
| 153 | u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`; |
| 154 | u.hash = ''; |
| 155 | return u.toString(); |
| 156 | } |