| 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 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. */ |
| 20 | export 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. */ |
| 24 | export 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. */ |
| 31 | export 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. */ |
| 39 | export 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 | |
| 49 | export interface ActMsg { |
| 50 | seat: SeatId; |
| 51 | act: Act; |
| 52 | } |
| 53 | |
| 54 | /** Ask the host for a seat. null means "any free one". */ |
| 55 | export 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 | */ |
| 64 | export type FxMsg = |
| 65 | | { fx: 'sfx'; kind: string; tile?: Tile } |
| 66 | | { fx: 'throw'; seat: SeatId; t: NetThrow }; |
| 67 | |
| 68 | export const EV_ACT = 'act'; |
| 69 | export const EV_SEAT = 'seat'; |
| 70 | export const EV_FX = 'fx'; |
| 71 | |
| 72 | export 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. */ |
| 80 | export 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. */ |
| 92 | export 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. */ |
| 101 | export const seatLink = (link: string, seat: SeatId) => |
| 102 | `${link}${link.includes('?') ? '&' : '?'}seat=${seat}`; |