anvilsign in

collin/mahjong

1import type { Claim, ClaimOption, GameState, SoundCue } from './engine';
2import type { Tile } from './tiles';
3import type { Rules, SeatId } from './types';
4
5/**
6 * How hard a tile left a phone, in the thrower's own frame: `u` is where along
7 * their edge it was let go of (0 = their left, 1 = their right), `vx` is speed
8 * along that edge, `vy` speed *into* the table, both px/s. The common screen
9 * turns this into a throw from that player's side of the table.
10 */
11export interface NetThrow {
12 u: number;
13 vx: number;
14 vy: number;
15 spin: number;
16}
17
18/**
19 * What the table's UI needs from whatever is running the game.
20 *
21 * `Game` satisfies this directly — the hotseat and solo tables talk straight to
22 * the engine, exactly as before. The online tables put `net/table.ts` here
23 * instead: same surface, but a call either drives the authoritative engine (on
24 * the host) or is sent to whoever has it. The UI cannot tell the difference,
25 * which is the point — every screen is drawn from a `GameState` and every
26 * button is one of these calls, wherever the state actually lives.
27 */
28export interface TableCtl {
29 readonly state: GameState;
30 subscribe(fn: () => void): () => void;
31 getSnapshot(): number;
32
33 discard(seat: SeatId, tile: Tile, thrown?: NetThrow): void;
34 respond(seat: SeatId, claim: Claim | 'pass'): void;
35 resolveNow(): void;
36 declareConcealedKong(seat: SeatId, tile: Tile): void;
37 declareAddedKong(seat: SeatId, tile: Tile): void;
38 declareSelfDraw(seat: SeatId): void;
39 reorderHand(seat: SeatId, from: number, to: number): void;
40 sortHand(seat: SeatId): void;
41 nextHand(): void;
42 newGame(bots?: boolean[], names?: string[]): void;
43 applySettings(names: string[], rules: Rules): void;
44
45 undoable(): { zh: string; en: string } | null;
46 undo(): void;
47
48 /**
49 * Throw the dice again, moving where the wall is broken and nothing else.
50 * Optional because only the engine has it: an online table's wall is the
51 * host's business. From the console; see `exposeGame` in ui/colliders.
52 */
53 reroll?(...dice: number[]): number[];
54
55 canSelfDrawWin(seat: SeatId): boolean;
56 concealedKongs(seat: SeatId): Tile[];
57 addedKongs(seat: SeatId): Tile[];
58 tenpai(seat: SeatId): Tile[];
59 sacredTiles(seat: SeatId): Tile[];
60 nextDrawer(): SeatId | null;
61
62 readonly hasBots: boolean;
63 isBot(seat: SeatId): boolean;
64 readonly wallLeft: number;
65 readonly roundWind: number;
66
67 onSound(fn: (cue: SoundCue) => void): () => void;
68}
69
70/** Claim options a seat is still owed an answer on, or undefined. */
71export const pendingOptions = (
72 state: GameState,
73 seat: SeatId,
74): ClaimOption[] | undefined =>
75 (state.phase === 'claims' || state.phase === 'robkong') &&
76 state.responses[seat] === undefined
77 ? state.options[seat]
78 : undefined;