anvilsign in

collin/mahjong

1import { joinRoom, type Room } from 'antics-sdk';
2import { AutoPlay } from '../game/autoplay';
3import type { NetThrow } from '../game/ctl';
4import type { SoundEvent } from '../game/engine';
5import type { Tile } from '../game/tiles';
6import type { Rules, SeatId } from '../game/types';
7import {
8 EMPTY_SEATS,
9 EV_ACT,
10 EV_FX,
11 EV_SEAT,
12 type ActMsg,
13 type FxMsg,
14 type NetMode,
15 type SeatMsg,
16 type SeatSlot,
17} from './protocol';
18import { NetTable } from './table';
19
20const SEATS: SeatId[] = [0, 1, 2, 3];
21
22/**
23 * One device's connection to a room, and everything that follows from it.
24 *
25 * antics elects a host, and the host runs the table: the real engine, the
26 * computer players, the answering of everyone else's intents. Everybody else
27 * mirrors the state the host publishes. If the host leaves, antics picks a new
28 * one, whose mirror is already current — it starts its own engine from what it
29 * was just watching and the game carries on.
30 *
31 * `role` is this device's, not the room's: the laptop that starts a party game
32 * is the `screen` everyone throws at; every other device is a `player`.
33 */
34export type NetRole = 'player' | 'screen';
35
36export class NetSession {
37 readonly room: Room;
38 readonly table: NetTable;
39 role: NetRole = 'player';
40 /** Latest connection trouble worth showing, or null. */
41 err: string | null = null;
42 /**
43 * A phone threw a tile: the full-size tables put it into their pool so it
44 * flies in from that player's edge. Wired by whoever owns a pool.
45 */
46 onThrowFx: (seat: SeatId, t: NetThrow) => void = () => {};
47
48 private autoplay: AutoPlay | null = null;
49 private stopAutoplay: (() => void) | null = null;
50 private busy: () => boolean = () => false;
51 private listeners = new Set<() => void>();
52 private version = 0;
53 private unsubs: (() => void)[] = [];
54
55 private constructor(room: Room) {
56 this.room = room;
57 this.table = new NetTable({
58 isAuthority: () => this.isHost,
59 mySeat: () => this.mySeat,
60 sendAct: (seat, act) => this.room.send(EV_ACT, { seat, act } as never),
61 });
62
63 const u = this.unsubs;
64 u.push(
65 room.onState(() => {
66 this.table.adopt(this.sharedGame());
67 this.syncAutoplay();
68 this.bump();
69 }),
70 room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)),
71 room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)),
72 room.on(EV_FX, (p) => this.handleFx(p as unknown as FxMsg)),
73 room.onJoin(() => this.bump()),
74 room.onLeave((p) => {
75 if (this.isHost) this.vacate(p.id);
76 this.bump();
77 }),
78 room.onHostChange(() => {
79 // A new host's engine is its mirror — already current. It only has to
80 // start acting like the host, which `syncAutoplay` and the guards on
81 // every handler do by asking `isHost` fresh each time.
82 this.syncAutoplay();
83 this.bump();
84 }),
85 room.onError((e) => {
86 this.err = `${e.message} — ${e.hint}`;
87 this.bump();
88 }),
89 // The host's own engine is the source of truth: publish every change.
90 this.table.engine.subscribe(() => {
91 if (this.isHost && this.mode) this.publishGame();
92 this.syncAutoplay();
93 }),
94 this.table.engine.onSound((cue) => {
95 if (!this.isHost) return;
96 this.table.emitSound(cue);
97 this.room.send(EV_FX, { fx: 'sfx', kind: cue.kind, tile: cue.tile } as never);
98 }),
99 );
100 // Mirror whatever the room already holds — a late joiner walks in on the
101 // game as it stands, not on an empty table.
102 this.table.adopt(this.sharedGame());
103 }
104
105 /** Join the room in the page URL, or create a fresh one. */
106 static async join(): Promise<NetSession> {
107 const room = await joinRoom({});
108 return new NetSession(room);
109 }
110
111 leave() {
112 for (const u of this.unsubs) u();
113 this.unsubs = [];
114 this.stopAutoplay?.();
115 this.stopAutoplay = null;
116 this.room.leave();
117 }
118
119 // ---- store plumbing ----------------------------------------------------
120 subscribe = (fn: () => void) => {
121 this.listeners.add(fn);
122 return () => {
123 this.listeners.delete(fn);
124 };
125 };
126 getSnapshot = () => this.version;
127 private bump() {
128 this.version++;
129 for (const l of this.listeners) l();
130 }
131
132 // ---- what the room says ------------------------------------------------
133 get isHost() {
134 return this.room.isHost;
135 }
136 get mode(): NetMode | null {
137 const m = this.room.state.mode;
138 return m === 'online' || m === 'party' ? m : null;
139 }
140 get seats(): SeatSlot[] {
141 const s = this.room.state.seats;
142 return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS;
143 }
144 private sharedGame(): unknown {
145 return this.room.state.game ?? null;
146 }
147 get mySeat(): SeatId | null {
148 const seats = this.seats;
149 for (const i of SEATS) if (seats[i].id === this.room.me.id) return i;
150 return null;
151 }
152 /** Whether a hand has actually been dealt in this room. */
153 get started(): boolean {
154 const g = this.table.engine.state;
155 return this.sharedGame() !== null && g.phase !== 'lobby';
156 }
157 /** Everyone here, for the lobby's presence line. */
158 get playerCount() {
159 return this.room.players.length;
160 }
161
162 // ---- lobby / starting --------------------------------------------------
163 /**
164 * Open an online game in this room: publish the mode and take a seat. Only
165 * the host can (room state is the host's to write); the button that calls
166 * this is only shown to the host.
167 */
168 openOnlineLobby() {
169 if (!this.isHost) return;
170 this.room.setState({ mode: 'online', seats: structuredClone(EMPTY_SEATS) as never });
171 this.claimSeat(0);
172 }
173
174 /** Deal the online game. Seats nobody claimed go to the computer. */
175 startOnline(rules: Rules) {
176 if (!this.isHost) return;
177 const seats = this.seats;
178 const bots = seats.map((s) => s.id === null);
179 if (bots.every(Boolean)) return; // nobody seated at all
180 const names = seats.map((s, i) => s.name || `玩家 ${i + 1}`);
181 const eng = this.table.engine;
182 eng.applySettings(names, rules);
183 eng.newGame(bots, names);
184 }
185
186 /**
187 * The party table: this device becomes the common screen and deals at once.
188 * All four seats start as people at this screen, exactly like hotseat — a
189 * phone that scans a seat's QR takes that seat over, and gives it back if it
190 * leaves.
191 */
192 startParty(names: string[], rules: Rules) {
193 if (!this.isHost) return;
194 this.role = 'screen';
195 const seats = EMPTY_SEATS.map((_, i) => ({ id: null, name: names[i] ?? '' }));
196 this.room.setState({ mode: 'party', seats: seats as never });
197 const eng = this.table.engine;
198 eng.applySettings(names, rules);
199 eng.newGame([false, false, false, false], names);
200 }
201
202 /** Ask for a seat — a numbered one, or any free one. */
203 claimSeat(seat: SeatId | null) {
204 if (this.isHost) this.handleSeat({ seat }, this.room.me.id);
205 else this.room.send(EV_SEAT, { seat } as never);
206 }
207
208 /** Hand a deserted seat to the computer (host's own button). */
209 handToBot(seat: SeatId) {
210 if (!this.isHost) return;
211 if (this.seats[seat].id !== null) return;
212 this.table.engine.setBot(seat, true);
213 }
214
215 /** The pool animations to wait on before a computer player moves. */
216 setBusy(busy: () => boolean) {
217 this.busy = busy;
218 }
219
220 // ---- host duties -------------------------------------------------------
221 private handleSeat(msg: SeatMsg, from: string) {
222 if (!this.isHost) return;
223 const name = this.room.player(from)?.name ?? '';
224 const seats = structuredClone(this.seats);
225 let seat = msg.seat;
226 if (seat === null) {
227 seat = SEATS.find((i) => seats[i].id === null) ?? null;
228 if (seat === null) return;
229 }
230 if (seats[seat].id !== null && seats[seat].id !== from) return; // taken
231 // One seat per player: claiming a new one lets go of the old.
232 for (const i of SEATS) if (seats[i].id === from) seats[i] = { id: null, name: seats[i].name };
233 seats[seat] = { id: from, name: name || seats[seat].name };
234 this.room.setState({ seats: seats as never });
235
236 // Mid-game, the seat comes back from the computer (or the common screen)
237 // to the person who claimed it.
238 if (this.sharedGame() !== null) {
239 this.table.engine.setBot(seat, false);
240 if (name) this.table.engine.renameSeat(seat, name);
241 }
242 this.bump();
243 }
244
245 private vacate(id: string) {
246 const seats = structuredClone(this.seats);
247 let changed = false;
248 for (const i of SEATS) {
249 if (seats[i].id === id) {
250 seats[i] = { id: null, name: seats[i].name };
251 changed = true;
252 }
253 }
254 if (changed) this.room.setState({ seats: seats as never });
255 // The seat stays human: at a party table the common screen simply plays it
256 // again, and online the host is offered the 電腦代打 button instead of the
257 // table deciding on its own that a dropped friend is gone for good.
258 }
259
260 private handleAct(msg: ActMsg, from: string) {
261 if (!this.isHost || !msg || typeof msg.seat !== 'number') return;
262 const seat = msg.seat;
263 // Their own seat — or one nobody holds, which is how the party screen
264 // plays its unclaimed seats when it is not the one running the engine
265 // (after a reload, say, when a phone has inherited the host's chair).
266 const holder = this.seats[seat]?.id;
267 if (holder !== from && holder !== null) return;
268 const eng = this.table.engine;
269 const act = msg.act;
270 switch (act.kind) {
271 case 'discard':
272 if (act.thrown) {
273 // Show the throw before the state lands: the pool holds the release
274 // until the discard it belongs to arrives.
275 this.onThrowFx(seat, act.thrown);
276 this.room.send(EV_FX, { fx: 'throw', seat, t: act.thrown } as never);
277 }
278 eng.discard(seat, act.tile as Tile);
279 break;
280 case 'respond':
281 eng.respond(seat, act.claim);
282 break;
283 case 'resolveNow':
284 if (eng.nextDrawer() === seat) eng.resolveNow();
285 break;
286 case 'ankong':
287 eng.declareConcealedKong(seat, act.tile as Tile);
288 break;
289 case 'addkong':
290 eng.declareAddedKong(seat, act.tile as Tile);
291 break;
292 case 'selfDraw':
293 eng.declareSelfDraw(seat);
294 break;
295 case 'nextHand':
296 eng.nextHand();
297 break;
298 case 'newGame':
299 eng.newGame(eng.state.bots);
300 break;
301 }
302 }
303
304 private handleFx(msg: FxMsg) {
305 if (this.isHost || !msg) return;
306 if (msg.fx === 'sfx') {
307 this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile });
308 } else if (msg.fx === 'throw') {
309 this.onThrowFx(msg.seat, msg.t);
310 }
311 }
312
313 private publishGame() {
314 // The engine mutates its state in place; the room keeps what it is handed,
315 // so it gets a copy that will hold still.
316 this.room.setState({ game: structuredClone(this.table.engine.state) as never });
317 }
318
319 /** The computer plays exactly when this device is the host of a live game. */
320 private syncAutoplay() {
321 const should = this.isHost && this.mode !== null && this.sharedGame() !== null;
322 if (should && !this.autoplay) {
323 this.autoplay = new AutoPlay(this.table.engine, () => this.busy());
324 this.stopAutoplay = this.autoplay.start();
325 } else if (!should && this.autoplay) {
326 this.stopAutoplay?.();
327 this.autoplay = null;
328 this.stopAutoplay = null;
329 }
330 }
331}