anvilsign in

collin/mahjong

1import type { NetThrow, TableCtl } from '../game/ctl';
2import { Game, type Claim, type GameState, type SoundCue } from '../game/engine';
3import { sortTiles, type Tile } from '../game/tiles';
4import type { Rules, SeatId } from '../game/types';
5import { reconcileOrder } from './handOrder';
6import type { Act } from './protocol';
7
8/**
9 * How the network session talks to the table. Kept this small so the table can
10 * be reasoned about (and tested) without a room behind it.
11 */
12export interface TableWire {
13 /** Whether this device is running the authoritative engine right now. */
14 isAuthority(): boolean;
15 /** The seat this device's player holds, if any. */
16 mySeat(): SeatId | null;
17 sendAct(seat: SeatId, act: Act): void;
18}
19
20/** How long an unanswered discard hides its tile before we admit it was lost. */
21const PENDING_TTL = 3000;
22
23/**
24 * The online table: the same surface the engine offers (`TableCtl`), backed by
25 * a `Game` that is either the real one or a mirror of the host's.
26 *
27 * On the host the inner engine is authoritative and calls go straight into it.
28 * Everywhere else the inner engine is a mirror kept current by `adopt`, and a
29 * call becomes an intent sent to the host — with two exceptions, both about
30 * your own hand, both because a round-trip inside a gesture is lag you can
31 * feel. Arranging your tiles never goes over the wire at all: the shared state
32 * carries the hand as a multiset and each device keeps its own arrangement
33 * (`handOrder.ts`). And a discard hides the tile at once, so the hand does not
34 * hold a tile you have visibly thrown while the wire does its round trip.
35 */
36export class NetTable implements TableCtl {
37 /** The engine. Authoritative on the host, a mirror everywhere else. */
38 readonly engine: Game;
39 /** Whether the undo button works here — the party screen, nowhere else. */
40 allowUndo = false;
41
42 private wire: TableWire;
43 private listeners = new Set<() => void>();
44 private version = 0;
45
46 // ---- the local arrangement of this player's own hand -------------------
47 private order: Tile[] = [];
48 private orderRev = 0;
49 /** The player slid the drawn tile into the row, so the 摸 marker comes off. */
50 private drawnMerged = false;
51 private drawnKey = '';
52 /** A discard sent but not yet echoed back, hidden from the hand meanwhile. */
53 private pending: { tile: Tile; len: number; at: number } | null = null;
54
55 private view: GameState | null = null;
56 private viewKey = '';
57 private lastAdopted: unknown = null;
58
59 private sounds = new Set<(cue: SoundCue) => void>();
60
61 constructor(wire: TableWire) {
62 this.wire = wire;
63 this.engine = new Game();
64 this.engine.persist = false;
65 this.engine.subscribe(() => this.bump());
66 }
67
68 // ---- store plumbing ----------------------------------------------------
69 subscribe = (fn: () => void) => {
70 this.listeners.add(fn);
71 return () => {
72 this.listeners.delete(fn);
73 };
74 };
75 getSnapshot = () => this.version;
76 private bump() {
77 this.version++;
78 for (const l of this.listeners) l();
79 }
80
81 /** The host published a new state and we are not the host: mirror it. */
82 adopt(shared: unknown) {
83 if (this.wire.isAuthority() || shared === this.lastAdopted || !shared) return;
84 this.lastAdopted = shared;
85 // The room hands its state out read-only and the engine mutates in place,
86 // so the mirror gets its own copy.
87 this.engine.restore(structuredClone(shared) as GameState);
88 }
89
90 // ---- the state the UI draws --------------------------------------------
91 get state(): GameState {
92 const seat = this.wire.mySeat();
93 const s = this.engine.state;
94 if (seat === null) return s;
95
96 const key = `${this.version}:${this.orderRev}`;
97 if (this.view && this.viewKey === key) return this.view;
98
99 // A fresh draw un-merges: the marker is about *this* tile, not the last.
100 const drawnKey = `${s.handNumber}:${s.turn}:${s.drawnTile}:${s.players[seat].hand.length}`;
101 if (drawnKey !== this.drawnKey) {
102 this.drawnKey = drawnKey;
103 this.drawnMerged = false;
104 }
105
106 this.order = reconcileOrder(this.order, s.players[seat].hand);
107 let hand = this.order;
108 const p = this.pending;
109 if (p) {
110 if (s.players[seat].hand.length <= p.len || Date.now() - p.at > PENDING_TTL) {
111 this.pending = null;
112 } else {
113 const i = hand.indexOf(p.tile);
114 if (i >= 0) hand = [...hand.slice(0, i), ...hand.slice(i + 1)];
115 }
116 }
117
118 const players = s.players.slice();
119 players[seat] = { ...players[seat], hand };
120 this.view = {
121 ...s,
122 players,
123 drawnSeparate: s.drawnSeparate && (s.turn !== seat || !this.drawnMerged),
124 };
125 this.viewKey = key;
126 return this.view;
127 }
128
129 // ---- actions -----------------------------------------------------------
130 private act(seat: SeatId, act: Act, run: () => void) {
131 if (this.wire.isAuthority()) run();
132 else this.wire.sendAct(seat, act);
133 }
134
135 discard(seat: SeatId, tile: Tile, thrown?: NetThrow) {
136 if (this.wire.isAuthority()) return this.engine.discard(seat, tile);
137 if (seat !== this.wire.mySeat()) return;
138 if (this.pending) return; // one throw at a time
139 const held = this.state.players[seat].hand.length;
140 this.wire.sendAct(seat, { kind: 'discard', tile, thrown });
141 this.pending = { tile, len: held - 1, at: Date.now() };
142 this.orderRev++;
143 this.bump();
144 }
145
146 respond(seat: SeatId, claim: Claim | 'pass') {
147 this.act(seat, { kind: 'respond', claim }, () => this.engine.respond(seat, claim));
148 }
149
150 resolveNow() {
151 if (this.wire.isAuthority()) return this.engine.resolveNow();
152 const seat = this.wire.mySeat();
153 if (seat !== null) this.wire.sendAct(seat, { kind: 'resolveNow' });
154 }
155
156 declareConcealedKong(seat: SeatId, tile: Tile) {
157 this.act(seat, { kind: 'ankong', tile }, () => this.engine.declareConcealedKong(seat, tile));
158 }
159
160 declareAddedKong(seat: SeatId, tile: Tile) {
161 this.act(seat, { kind: 'addkong', tile }, () => this.engine.declareAddedKong(seat, tile));
162 }
163
164 declareSelfDraw(seat: SeatId) {
165 this.act(seat, { kind: 'selfDraw' }, () => this.engine.declareSelfDraw(seat));
166 }
167
168 nextHand() {
169 if (this.wire.isAuthority()) return this.engine.nextHand();
170 const seat = this.wire.mySeat();
171 if (seat !== null) this.wire.sendAct(seat, { kind: 'nextHand' });
172 }
173
174 newGame(bots?: boolean[], names?: string[]) {
175 if (this.wire.isAuthority()) return this.engine.newGame(bots, names);
176 const seat = this.wire.mySeat();
177 if (seat !== null) this.wire.sendAct(seat, { kind: 'newGame' });
178 }
179
180 applySettings(names: string[], rules: Rules) {
181 if (this.wire.isAuthority()) this.engine.applySettings(names, rules);
182 }
183
184 // Your own hand is arranged on your own device and nowhere else. Somebody
185 // else's hand only reaches here on the party screen, whose engine is real.
186 reorderHand(seat: SeatId, from: number, to: number) {
187 if (seat === this.wire.mySeat()) {
188 const hand = this.order;
189 if (from === to || from < 0 || to < 0 || from >= hand.length || to >= hand.length) return;
190 const s = this.engine.state;
191 const last = hand.length - 1;
192 if (s.drawnSeparate && s.turn === seat && (from === last || to === last)) this.drawnMerged = true;
193 const [t] = hand.splice(from, 1);
194 hand.splice(to, 0, t);
195 this.orderRev++;
196 this.bump();
197 return;
198 }
199 if (this.wire.isAuthority()) this.engine.reorderHand(seat, from, to);
200 }
201
202 sortHand(seat: SeatId) {
203 if (seat === this.wire.mySeat()) {
204 this.order = sortTiles(this.order);
205 this.drawnMerged = true;
206 this.orderRev++;
207 this.bump();
208 return;
209 }
210 if (this.wire.isAuthority()) this.engine.sortHand(seat);
211 }
212
213 // ---- undo --------------------------------------------------------------
214 undoable() {
215 return this.wire.isAuthority() && this.allowUndo ? this.engine.undoable() : null;
216 }
217 undo() {
218 if (this.wire.isAuthority() && this.allowUndo) this.engine.undo();
219 }
220
221 // ---- queries, all answered by the engine off the same state ------------
222 canSelfDrawWin(seat: SeatId) {
223 return this.engine.canSelfDrawWin(seat);
224 }
225 concealedKongs(seat: SeatId) {
226 return this.engine.concealedKongs(seat);
227 }
228 addedKongs(seat: SeatId) {
229 return this.engine.addedKongs(seat);
230 }
231 tenpai(seat: SeatId) {
232 return this.engine.tenpai(seat);
233 }
234 sacredTiles(seat: SeatId) {
235 return this.engine.sacredTiles(seat);
236 }
237 nextDrawer() {
238 return this.engine.nextDrawer();
239 }
240 get hasBots() {
241 return this.engine.hasBots;
242 }
243 isBot(seat: SeatId) {
244 return this.engine.isBot(seat);
245 }
246 get wallLeft() {
247 return this.engine.wallLeft;
248 }
249 get roundWind() {
250 return this.engine.roundWind;
251 }
252
253 // ---- sound -------------------------------------------------------------
254 /** Fed by the session: the engine's own cues on the host, fx events off it. */
255 emitSound(cue: SoundCue) {
256 for (const l of this.sounds) l(cue);
257 }
258 onSound = (fn: (cue: SoundCue) => void) => {
259 this.sounds.add(fn);
260 return () => {
261 this.sounds.delete(fn);
262 };
263 };
264}