| 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 | * 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 | */ |
| 32 | export 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. */ |
| 39 | export 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. */ |
| 69 | export 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 | |
| 82 | export interface ActMsg { |
| 83 | seat: SeatId; |
| 84 | act: Act; |
| 85 | } |
| 86 | |
| 87 | /** Ask the host for a seat. null means "any free one". */ |
| 88 | export 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 | */ |
| 97 | export 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 | */ |
| 106 | export interface VoiceMsg { |
| 107 | ready: boolean; |
| 108 | } |
| 109 | |
| 110 | export const EV_ACT = 'act'; |
| 111 | export const EV_SEAT = 'seat'; |
| 112 | export const EV_FX = 'fx'; |
| 113 | export const EV_VOICE = 'voice'; |
| 114 | |
| 115 | export 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. */ |
| 123 | export 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. */ |
| 135 | export 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 | */ |
| 160 | export 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 | } |