| 1 | import type { NetThrow, TableCtl } from '../game/ctl'; |
| 2 | import { Game, type Claim, type GameState, type SoundCue } from '../game/engine'; |
| 3 | import { sortTiles, type Tile } from '../game/tiles'; |
| 4 | import type { Rules, SeatId } from '../game/types'; |
| 5 | import { reconcileOrder } from './handOrder'; |
| 6 | import 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 | */ |
| 12 | export 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. */ |
| 21 | const 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 | */ |
| 36 | export 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 | } |