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