anvilsign in

collin/mahjong

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