anvilsign in

collin/mahjong

1import { isWinningHand, waits } from './hu';
2import { dealerTai, scoreHand, type TaiItem } from './tai';
3import {
4 buildWall,
5 EAST,
6 isFlower,
7 isSuited,
8 labelOf,
9 makeRng,
10 RED,
11 shuffle,
12 sortTiles,
13 WHITE,
14 WIND_NAMES,
15 type Tile,
16} from './tiles';
17import { clearSave, saveGame } from './save';
18import {
19 BOT_NAMES,
20 DEFAULT_NAMES,
21 DEFAULT_RULES,
22 type Meld,
23 type Player,
24 type Rules,
25 type SeatId,
26} from './types';
27import { rollDice, type Dice } from './wall';
28
29export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd';
30
31/**
32 * Things worth hearing. The engine only names them; what they sound like, and
33 * whether anything is audible at all, is the UI's business (`sound.ts`).
34 */
35export type SoundEvent =
36 | 'deal'
37 | 'discard'
38 | 'claimWindow'
39 // The four calls, kept distinct because they are things a player shouts:
40 // with voice turned on the UI says them out loud.
41 | 'pung'
42 | 'chow'
43 | 'kong'
44 | 'hu'
45 | 'selfDraw'
46 | 'flower'
47 | 'drawGame'
48 | 'undo';
49
50/** A cue and, where there is one, the tile it is about. */
51export interface SoundCue {
52 kind: SoundEvent;
53 tile?: Tile;
54 /**
55 * Whose it is — the seat that threw, called, or won. Absent for the things
56 * that belong to the table rather than to anybody: the deal, a 流局, an undo.
57 *
58 * The engine says whose only because a sound has to come from somewhere: the
59 * UI pans it to that seat's edge, and at a party table it can hand the words
60 * to the phone that seat is holding. See ui/sound.ts.
61 */
62 seat?: SeatId;
63}
64
65export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow';
66
67export interface ClaimOption {
68 type: ClaimType;
69 /** For a chow: the two tiles from hand that complete it. */
70 with?: Tile[];
71}
72
73export interface Claim {
74 type: ClaimType;
75 with?: Tile[];
76}
77
78/** One seat's win. A hand has several of these only under 一炮多響. */
79export interface WinnerResult {
80 seat: SeatId;
81 selfDraw: boolean;
82 tai: number;
83 items: TaiItem[];
84 winTile: Tile;
85 /** 包牌 — the seat answering for this hand on its own, if any. */
86 liable: SeatId | null;
87}
88
89export interface HandResult {
90 drawGame: boolean;
91 /** Empty on a 流局. */
92 winners: WinnerResult[];
93 /** The discarder, when the hand was won off a discard. */
94 loser: SeatId | null;
95 deltas: number[];
96}
97
98export interface GameState {
99 rules: Rules;
100 players: Player[];
101 /**
102 * Which seats the computer plays. All false is the hotseat game everyone
103 * shares one screen for; seats 1-3 true is the single-player table. It lives
104 * in the state rather than beside it so a saved game resumes as the kind of
105 * game it was.
106 */
107 bots: boolean[];
108 wall: Tile[];
109 dealer: SeatId;
110 dealerStreak: number;
111 rotations: number; // completed dealer passes; roundWind = floor(rotations / 4)
112 turn: SeatId;
113 phase: Phase;
114 drawnTile: Tile | null;
115 /** True while the drawn tile is still held apart at the end of the hand. */
116 drawnSeparate: boolean;
117 lastDiscard: { tile: Tile; from: SeatId } | null;
118 /** Seats that may respond to the tile on the table, and with what. */
119 options: Partial<Record<SeatId, ClaimOption[]>>;
120 responses: Partial<Record<SeatId, Claim | 'pass'>>;
121 /**
122 * 過水 — per seat, the tiles they have already declined to win on since
123 * their last draw. Those tiles are dead to them until the lock lifts.
124 */
125 sacred: Tile[][];
126 afterKong: boolean;
127 robKongTile: Tile | null;
128 /**
129 * 擲骰 — what the dealer threw to open this hand. Where the wall is broken
130 * and nothing else: the tiles were shuffled before the square was built, so
131 * the dice move the gap round the table rather than change anybody's hand.
132 * `breakAt` in game/wall.ts reads them.
133 */
134 dice: Dice;
135 /** How many of the original 144 wall positions have been taken off the front. */
136 drawnFront: number;
137 /** ...and off the back (kong / flower replacements). */
138 drawnBack: number;
139 result: HandResult | null;
140 log: string[];
141 handNumber: number;
142}
143
144const SEATS: SeatId[] = [0, 1, 2, 3];
145const next = (s: SeatId): SeatId => (((s + 1) % 4) as SeatId);
146
147/** How many actions back you can walk. Deeper than anyone will remember. */
148const UNDO_DEPTH = 20;
149
150interface Snapshot {
151 state: GameState;
152 /** What pressing undo will take back, in the players' own words. */
153 zh: string;
154 en: string;
155}
156
157export class Game {
158 state: GameState;
159 private listeners = new Set<() => void>();
160 private version = 0;
161 /**
162 * Whether moves are written to localStorage. Off for the online tables: the
163 * authoritative copy lives in the room, a mirror of somebody else's game must
164 * not overwrite the local solo save — and the sandbox a deployed game runs in
165 * has no storage to write to anyway.
166 */
167 persist = true;
168
169 constructor(names: string[] = DEFAULT_NAMES, rules: Rules = DEFAULT_RULES) {
170 this.state = {
171 rules,
172 players: names.map((name) => ({
173 name,
174 hand: [],
175 melds: [],
176 flowers: [],
177 discards: [],
178 score: rules.startingScore,
179 })),
180 bots: [false, false, false, false],
181 wall: [],
182 dealer: 0,
183 dealerStreak: 0,
184 rotations: 0,
185 turn: 0,
186 phase: 'lobby',
187 drawnTile: null,
188 drawnSeparate: false,
189 lastDiscard: null,
190 options: {},
191 responses: {},
192 sacred: [[], [], [], []],
193 afterKong: false,
194 robKongTile: null,
195 dice: [1, 1, 1],
196 drawnFront: 0,
197 drawnBack: 0,
198 result: null,
199 log: [],
200 handNumber: 0,
201 };
202 }
203
204 // ---- store plumbing ----------------------------------------------------
205 subscribe = (fn: () => void) => {
206 this.listeners.add(fn);
207 return () => this.listeners.delete(fn);
208 };
209 getSnapshot = () => this.version;
210 private emit() {
211 this.version++;
212 if (this.persist) saveGame(this.state);
213 for (const l of this.listeners) l();
214 }
215
216 // ---- undo --------------------------------------------------------------
217 /**
218 * Four people share one touchscreen, so a mis-tap is the likeliest complaint
219 * at the table. The whole state is plain JSON, so taking one back is just a
220 * matter of having kept a copy.
221 *
222 * Snapshots are pushed by the methods a *button* calls — discard, respond,
223 * the kong and win declarations — not by the internal steps they run, so one
224 * press is one undo. They live outside the state, which is why an undo does
225 * not survive a reload: what you can take back is what happened while
226 * everyone was still sitting there watching it happen.
227 */
228 private undoStack: Snapshot[] = [];
229
230 private push(zh: string, en: string) {
231 this.undoStack.push({ state: JSON.parse(JSON.stringify(this.state)) as GameState, zh, en });
232 if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift();
233 }
234
235 /**
236 * Which snapshot an undo would restore. At a hotseat table that is simply
237 * the last one. With computer players it is the last point a *person* had a
238 * decision to make: rewinding into the middle of the computer's turn would
239 * only hand the move straight back to it.
240 */
241 private undoIndex(): number {
242 if (this.undoStack.length === 0) return -1;
243 if (!this.hasBots) return this.undoStack.length - 1;
244 for (let i = this.undoStack.length - 1; i >= 0; i--) {
245 if (humanCanAct(this.undoStack[i].state)) return i;
246 }
247 return -1;
248 }
249
250 /** What undo would take back right now, or null if there is nothing. */
251 undoable(): { zh: string; en: string } | null {
252 const i = this.undoIndex();
253 if (i < 0) return null;
254 // Against the computer an undo swallows its replies too, so naming only
255 // the most recent move would be a lie about what is coming back.
256 if (this.hasBots) return { zh: '上一步', en: 'your last move' };
257 const snap = this.undoStack[i];
258 return { zh: snap.zh, en: snap.en };
259 }
260
261 undo() {
262 const i = this.undoIndex();
263 if (i < 0) return;
264 this.state = this.undoStack[i].state;
265 this.undoStack.length = i;
266 this.sfx('undo');
267 this.emit();
268 }
269
270 /**
271 * Throw the dice again without touching the tiles — `game.reroll(6, 6, 6)`
272 * from the console, or `game.reroll()` for a fresh throw.
273 *
274 * Only the break moves, which is the only thing the dice decide here: the
275 * wall was shuffled before it was built, so nobody's hand changes and the
276 * draw order is the draw order. It is how the square gets looked at broken
277 * somewhere other than wherever this hand happened to open.
278 */
279 reroll(...dice: number[]) {
280 this.state.dice =
281 dice.length === 3 ? (dice as Dice) : rollDice(makeRng(Math.floor(Math.random() * 2 ** 31)));
282 this.emit();
283 return this.state.dice;
284 }
285
286 get hasBots() {
287 return this.state.bots.some(Boolean);
288 }
289 isBot(seat: SeatId) {
290 return this.state.bots[seat];
291 }
292
293 private soundListeners = new Set<(cue: SoundCue) => void>();
294 /** Sound is a side channel, not state: it is never saved and never replayed. */
295 onSound = (fn: (cue: SoundCue) => void) => {
296 this.soundListeners.add(fn);
297 return () => {
298 this.soundListeners.delete(fn);
299 };
300 };
301 private sfx(kind: SoundEvent, tile?: Tile, seat?: SeatId) {
302 for (const l of this.soundListeners) l({ kind, tile, seat });
303 }
304
305 /**
306 * Take the settings screen's word for the names and the stakes. Chips are
307 * only re-dealt before the first hand — changing 底 mid-game shouldn't
308 * rewrite what everyone has already won.
309 */
310 applySettings(names: string[], rules: Rules) {
311 const s = this.state;
312 s.rules = rules;
313 names.forEach((name, i) => {
314 // The computer keeps the name it was given; the settings screen is about
315 // the people at the table.
316 if (!s.bots[i]) s.players[i].name = name;
317 });
318 if (s.phase === 'lobby') for (const p of s.players) p.score = rules.startingScore;
319 this.emit();
320 }
321
322 /** Pick up a game saved earlier. */
323 restore(state: GameState) {
324 this.state = state;
325 this.emit();
326 }
327
328 /** One nameplate, not the whole settings screen — a phone claimed a seat. */
329 renameSeat(seat: SeatId, name: string) {
330 if (!name || this.state.players[seat].name === name) return;
331 this.state.players[seat].name = name;
332 this.emit();
333 }
334
335 /**
336 * Hand a seat to the computer, or take it back. This is what happens online
337 * when a player drops mid-hand: their seat cannot hold the table forever, and
338 * it cannot simply be skipped either — somebody has to play it.
339 */
340 setBot(seat: SeatId, bot: boolean) {
341 if (this.state.bots[seat] === bot) return;
342 this.state.bots[seat] = bot;
343 this.emit();
344 }
345 private say(msg: string) {
346 this.state.log.unshift(msg);
347 if (this.state.log.length > 40) this.state.log.pop();
348 }
349
350 get roundWind() {
351 return Math.floor(this.state.rotations / 4) % 4;
352 }
353 get wallLeft() {
354 return this.state.wall.length - this.state.rules.wallReserve;
355 }
356
357 // ---- hand setup --------------------------------------------------------
358 startHand(seed = Math.floor(Math.random() * 2 ** 31)) {
359 const s = this.state;
360 // Once the next hand is dealt the last one is settled and paid; there is
361 // nothing left to take back, and the tiles are already shuffled away.
362 this.undoStack = [];
363 // One rng for the whole hand's setup, so the same seed rebuilds the same
364 // wall *and* throws the same dice — a hand replayed is the hand that was
365 // played, gap and all.
366 const rng = makeRng(seed);
367 s.wall = shuffle(buildWall(), rng);
368 s.dice = rollDice(rng);
369 s.handNumber++;
370 for (const p of s.players) {
371 p.hand = [];
372 p.melds = [];
373 p.flowers = [];
374 p.discards = [];
375 }
376 s.result = null;
377 s.lastDiscard = null;
378 s.options = {};
379 s.responses = {};
380 s.sacred = [[], [], [], []];
381 s.afterKong = false;
382 s.robKongTile = null;
383 s.drawnFront = 0;
384 s.drawnBack = 0;
385 s.log = [];
386
387 for (let round = 0; round < 4; round++) {
388 for (let i = 0; i < 4; i++) {
389 const seat = ((s.dealer + i) % 4) as SeatId;
390 for (let k = 0; k < 4; k++) s.players[seat].hand.push(this.takeFront());
391 }
392 }
393 // The dealer's 17th tile is the opening draw.
394 const opener = this.takeFront();
395 s.players[s.dealer].hand.push(opener);
396
397 // 補花 — replace flowers in seat order, repeating until nobody holds one.
398 let dealerLast: Tile | null = null;
399 let more = true;
400 while (more) {
401 more = false;
402 for (let i = 0; i < 4; i++) {
403 const seat = ((s.dealer + i) % 4) as SeatId;
404 const r = this.replaceFlowers(seat);
405 if (r.moved) more = true;
406 if (seat === s.dealer && r.last !== null) dealerLast = r.last;
407 }
408 }
409 for (const p of s.players) p.hand = sortTiles(p.hand);
410
411 s.turn = s.dealer;
412 this.setDrawn(s.dealer, isFlower(opener) ? dealerLast : opener);
413 s.phase = 'turn';
414 this.say(`${WIND_NAMES[this.roundWind]}圈 — 莊家 dealer: ${s.players[s.dealer].name}`);
415 this.sfx('deal');
416 this.emit();
417 }
418
419 /**
420 * Moves flowers out of a hand, drawing replacements from the back of the wall.
421 * Returns the last replacement tile so the caller can keep marking it as the
422 * tile the player just drew.
423 */
424 private replaceFlowers(seat: SeatId): { moved: boolean; last: Tile | null } {
425 const s = this.state;
426 const p = s.players[seat];
427 let moved = false;
428 let last: Tile | null = null;
429 for (;;) {
430 const i = p.hand.findIndex(isFlower);
431 if (i < 0) break;
432 const f = p.hand.splice(i, 1)[0];
433 p.flowers.push(f);
434 p.flowers.sort((a, b) => a - b);
435 moved = true;
436 last = null;
437 if (s.wall.length === 0) break;
438 last = this.takeBack();
439 p.hand.push(last);
440 }
441 return { moved, last };
442 }
443
444 /**
445 * Park the tile a player just drew at the end of their hand, held apart from
446 * the rest. Hands are never auto-sorted after the deal — the arrangement is
447 * the player's, so we only ever append.
448 */
449 private setDrawn(seat: SeatId, tile: Tile | null) {
450 const s = this.state;
451 s.drawnTile = tile;
452 s.drawnSeparate = tile !== null;
453 if (tile === null) return;
454 const hand = s.players[seat].hand;
455 const i = hand.lastIndexOf(tile);
456 if (i >= 0) hand.splice(i, 1);
457 hand.push(tile);
458 }
459
460 /** Move a tile within a hand. Players arrange their own tiles however they like. */
461 reorderHand(seat: SeatId, from: number, to: number) {
462 const s = this.state;
463 const hand = s.players[seat].hand;
464 if (from === to || from < 0 || to < 0 || from >= hand.length || to >= hand.length) return;
465 const last = hand.length - 1;
466 // Sliding the drawn tile into the hand (or something past it) merges it in.
467 if (s.drawnSeparate && s.turn === seat && (from === last || to === last)) s.drawnSeparate = false;
468 const [t] = hand.splice(from, 1);
469 hand.splice(to, 0, t);
470 this.emit();
471 }
472
473 /** 理牌 — sort a hand back into suit order. */
474 sortHand(seat: SeatId) {
475 const s = this.state;
476 s.players[seat].hand = sortTiles(s.players[seat].hand);
477 if (s.turn === seat) s.drawnSeparate = false;
478 this.emit();
479 }
480
481 /** Next tile off the front of the wall (the normal draw). */
482 private takeFront(): Tile {
483 this.state.drawnFront++;
484 return this.state.wall.shift()!;
485 }
486
487 /** Next tile off the back of the wall (kong and flower replacements). */
488 private takeBack(): Tile {
489 this.state.drawnBack++;
490 return this.state.wall.pop()!;
491 }
492
493 // ---- turn flow ---------------------------------------------------------
494 private beginTurn(seat: SeatId) {
495 const s = this.state;
496 if (this.wallLeft <= 0) return this.endInDraw();
497 const t = this.takeFront();
498 s.players[seat].hand.push(t);
499 let drawn: Tile | null = t;
500 if (isFlower(t)) {
501 drawn = this.replaceFlowers(seat).last;
502 this.say(`${s.players[seat].name} 補花 flower`);
503 this.sfx('flower', t, seat); // the flower itself, not the tile that replaced it
504 }
505 s.turn = seat;
506 this.setDrawn(seat, drawn);
507 s.phase = 'turn';
508 s.afterKong = false;
509 s.lastDiscard = null;
510 s.options = {};
511 s.responses = {};
512 // Your own draw always lifts 過水, whatever the table thinks about claims.
513 s.sacred[seat] = [];
514 }
515
516 // ---- 過水 ---------------------------------------------------------------
517 /** Has this seat already passed on winning with this tile? */
518 private isSacred(seat: SeatId, tile: Tile): boolean {
519 const s = this.state;
520 return s.rules.sacredDiscard && s.sacred[seat].includes(tile);
521 }
522
523 /**
524 * Everyone who was offered 胡 on the tile now on the table and did not take
525 * it has declined it — including anyone who took a 碰 or 吃 instead. Called
526 * once the claims are settled, so the seat that actually won is exempt.
527 */
528 private recordDeclinedWins(winners: SeatId[]) {
529 const s = this.state;
530 if (!s.rules.sacredDiscard) return;
531 const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile;
532 if (tile === undefined || tile === null) return;
533 for (const seat of SEATS) {
534 if (winners.includes(seat)) continue;
535 if (!s.options[seat]?.some((o) => o.type === 'hu')) continue;
536 if (!s.sacred[seat].includes(tile)) s.sacred[seat].push(tile);
537 }
538 }
539
540 /** Replacement tile after a kong, taken from the back of the wall. */
541 private kongReplacement(seat: SeatId) {
542 const s = this.state;
543 if (s.wall.length === 0) return this.endInDraw();
544 const t = this.takeBack();
545 s.players[seat].hand.push(t);
546 const drawn = isFlower(t) ? this.replaceFlowers(seat).last : t;
547 this.setDrawn(seat, drawn);
548 s.turn = seat;
549 s.phase = 'turn';
550 s.afterKong = true;
551 s.sacred[seat] = []; // a replacement off the back is still a draw
552 }
553
554 /** 過水 — tiles this seat has passed on winning with, for the badge. */
555 sacredTiles(seat: SeatId): Tile[] {
556 return this.state.rules.sacredDiscard ? this.state.sacred[seat] : [];
557 }
558
559 discard(seat: SeatId, tile: Tile) {
560 const s = this.state;
561 if (s.phase !== 'turn' || s.turn !== seat) return;
562 const p = s.players[seat];
563 const i = p.hand.indexOf(tile);
564 if (i < 0) return;
565 this.push(`${p.name} 打 ${labelOf(tile)}`, `${p.name} discarded ${labelOf(tile)}`);
566 p.hand.splice(i, 1);
567 p.discards.push(tile);
568 s.drawnTile = null;
569 s.drawnSeparate = false;
570 s.lastDiscard = { tile, from: seat };
571 s.responses = {};
572 s.options = this.claimOptionsFor(tile, seat);
573 this.sfx('discard', tile, seat);
574
575 if (Object.keys(s.options).length === 0) {
576 this.afterDiscardResolved();
577 } else {
578 s.phase = 'claims';
579 // The point of the chime: claims don't block, so somebody has to be told
580 // their window is open before the next player draws it shut. It comes
581 // from the tile — that is, from the seat that threw it — because that is
582 // what everybody else is being asked to look at.
583 this.sfx('claimWindow', undefined, seat);
584 }
585 this.emit();
586 }
587
588 /** Called once nobody wants the discarded tile. */
589 private afterDiscardResolved() {
590 const s = this.state;
591 const from = s.lastDiscard!.from;
592 if (this.wallLeft <= 0) return this.endInDraw();
593 this.beginTurn(next(from));
594 }
595
596 private claimOptionsFor(tile: Tile, from: SeatId): Partial<Record<SeatId, ClaimOption[]>> {
597 const s = this.state;
598 const out: Partial<Record<SeatId, ClaimOption[]>> = {};
599 for (const seat of SEATS) {
600 if (seat === from) continue;
601 const p = s.players[seat];
602 const opts: ClaimOption[] = [];
603 if (isWinningHand([...p.hand, tile], p.melds.length) && !this.isSacred(seat, tile)) {
604 opts.push({ type: 'hu' });
605 }
606 const same = p.hand.filter((t) => t === tile).length;
607 if (same >= 3 && this.wallLeft > 0) opts.push({ type: 'kong' });
608 if (same >= 2) opts.push({ type: 'pung' });
609 if (seat === next(from) && isSuited(tile)) {
610 for (const pair of chowPartners(p.hand, tile)) opts.push({ type: 'chow', with: pair });
611 }
612 if (opts.length) out[seat] = opts;
613 }
614 return out;
615 }
616
617 /**
618 * The seat that draws next if nobody takes the tile on the table — they are
619 * the ones who can close the claim window.
620 */
621 nextDrawer(): SeatId | null {
622 const s = this.state;
623 if (s.phase === 'claims' && s.lastDiscard) return next(s.lastDiscard.from);
624 if (s.phase === 'robkong') return s.turn;
625 return null;
626 }
627
628 /**
629 * Carry on without waiting for anyone still thinking: everyone who hasn't
630 * answered passes. Claims already declared still stand, so a player who
631 * called in time keeps the tile. This is what stops one distracted player
632 * from halting the table.
633 */
634 resolveNow() {
635 const s = this.state;
636 if (s.phase !== 'claims' && s.phase !== 'robkong') return;
637 this.push('關閉叫牌', 'Closed the claim window');
638 for (const key of Object.keys(s.options)) {
639 const seat = Number(key) as SeatId;
640 if (s.responses[seat] === undefined) s.responses[seat] = 'pass';
641 }
642 this.tryResolveClaims();
643 this.emit();
644 }
645
646 respond(seat: SeatId, claim: Claim | 'pass') {
647 const s = this.state;
648 if (s.phase !== 'claims' && s.phase !== 'robkong') return;
649 if (!s.options[seat]) return;
650 const name = s.players[seat].name;
651 if (claim === 'pass') this.push(`${name} 過`, `${name} passed`);
652 else this.push(`${name} ${CLAIM_ZH[claim.type]}`, `${name} called ${CLAIM_EN[claim.type].toLowerCase()}`);
653 s.responses[seat] = claim;
654 this.tryResolveClaims();
655 this.emit();
656 }
657
658 private tryResolveClaims() {
659 const s = this.state;
660 const rank = (c: ClaimType) => (c === 'hu' ? 3 : c === 'chow' ? 1 : 2);
661 const pending = (Object.keys(s.options) as unknown as SeatId[])
662 .map(Number)
663 .filter((seat) => s.responses[seat as SeatId] === undefined) as SeatId[];
664
665 const declared = (Object.entries(s.responses) as [string, Claim | 'pass'][])
666 .filter(([, c]) => c !== 'pass')
667 .map(([seat, c]) => ({ seat: Number(seat) as SeatId, claim: c as Claim }));
668
669 if (declared.length === 0) {
670 if (pending.length > 0) return;
671 // everyone passed
672 this.recordDeclinedWins([]);
673 if (s.phase === 'robkong') return this.finishAddKong();
674 return this.afterDiscardResolved();
675 }
676
677 const bestPending = Math.max(
678 0,
679 ...pending.map((seat) => Math.max(...s.options[seat]!.map((o) => rank(o.type)))),
680 );
681 const best = Math.max(...declared.map((d) => rank(d.claim.type)));
682 if (best < bestPending && pending.length > 0) return; // someone might outrank
683
684 const from = s.lastDiscard?.from ?? s.turn;
685 const winners = declared.filter((d) => rank(d.claim.type) === best);
686 // Closest player after the discarder wins a tie.
687 winners.sort(
688 (a, b) => ((a.seat - from + 4) % 4) - ((b.seat - from + 4) % 4),
689 );
690
691 if (best === rank('hu')) {
692 // Anyone else who can win on this tile is worth waiting for: under
693 // 一炮多響 they get paid too, and without it the tile still belongs to
694 // the seat nearest the discarder rather than to the quickest hand. The
695 // Draw button closes the window either way, so nobody is held up.
696 if (pending.some((seat) => s.options[seat]!.some((o) => o.type === 'hu'))) return;
697 const seats = s.rules.multipleWinners ? winners.map((w) => w.seat) : [winners[0].seat];
698 this.recordDeclinedWins(seats);
699 return this.winOnTile(seats);
700 }
701
702 this.recordDeclinedWins([winners[0].seat]);
703 this.executeClaim(winners[0].seat, winners[0].claim);
704 }
705
706 /** Everyone who called 胡 on the tile currently on the table takes it. */
707 private winOnTile(seats: SeatId[]) {
708 const s = this.state;
709 const robKong = s.phase === 'robkong';
710 const tile = robKong ? s.robKongTile! : s.lastDiscard!.tile;
711 const from = robKong ? s.turn : s.lastDiscard!.from;
712 this.settle(
713 seats.map((seat) => ({ seat, winTile: tile, selfDraw: false, robKong })),
714 from,
715 );
716 }
717
718 /** Melds the tile on the table into a hand. 胡 goes through `winOnTile`. */
719 private executeClaim(seat: SeatId, claim: Claim) {
720 const s = this.state;
721 if (claim.type === 'hu') return this.winOnTile([seat]);
722 const tile = s.phase === 'robkong' ? s.robKongTile! : s.lastDiscard!.tile;
723 const from = s.phase === 'robkong' ? s.turn : s.lastDiscard!.from;
724 const p = s.players[seat];
725
726 // Take the tile off the discard pile.
727 if (s.lastDiscard) {
728 const dp = s.players[from].discards;
729 if (dp[dp.length - 1] === tile) dp.pop();
730 }
731 const take = (t: Tile, n: number) => {
732 for (let k = 0; k < n; k++) p.hand.splice(p.hand.indexOf(t), 1);
733 };
734
735 let meld: Meld;
736 if (claim.type === 'kong') {
737 take(tile, 3);
738 meld = { kind: 'kong', tiles: [tile, tile, tile, tile], from };
739 } else if (claim.type === 'pung') {
740 take(tile, 2);
741 meld = { kind: 'pung', tiles: [tile, tile, tile], from };
742 } else {
743 for (const t of claim.with!) take(t, 1);
744 meld = { kind: 'chow', tiles: sortTiles([tile, ...claim.with!]), from };
745 }
746 p.melds.push(meld);
747 // Some tables treat taking a tile as enough of a fresh start to lift 過水;
748 // stricter ones make you wait for your own draw.
749 if (s.rules.sacredClearedByClaim) s.sacred[seat] = [];
750 this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`);
751 this.sfx(claim.type, tile, seat);
752
753 s.lastDiscard = null;
754 s.options = {};
755 s.responses = {};
756 if (claim.type === 'kong') {
757 this.kongReplacement(seat);
758 } else {
759 s.turn = seat;
760 s.drawnTile = null;
761 s.drawnSeparate = false;
762 s.phase = 'turn';
763 s.afterKong = false;
764 }
765 }
766
767 // ---- own-turn declarations --------------------------------------------
768 /** Tiles the seat may declare a concealed kong on. */
769 concealedKongs(seat: SeatId): Tile[] {
770 const p = this.state.players[seat];
771 const counts = new Map<Tile, number>();
772 for (const t of p.hand) counts.set(t, (counts.get(t) ?? 0) + 1);
773 return [...counts.entries()].filter(([, n]) => n === 4).map(([t]) => t);
774 }
775
776 /** Tiles that can be added to an existing exposed pung. */
777 addedKongs(seat: SeatId): Tile[] {
778 const p = this.state.players[seat];
779 return p.melds
780 .filter((m) => m.kind === 'pung')
781 .map((m) => m.tiles[0])
782 .filter((t) => p.hand.includes(t));
783 }
784
785 declareConcealedKong(seat: SeatId, tile: Tile) {
786 const s = this.state;
787 if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return;
788 const p = s.players[seat];
789 this.push(`${p.name} 暗槓 ${labelOf(tile)}`, `${p.name} declared a concealed kong`);
790 for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1);
791 p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] });
792 this.say(`${p.name} 暗槓 concealed kong`);
793 this.sfx('kong', tile, seat);
794 this.kongReplacement(seat);
795 this.emit();
796 }
797
798 declareAddedKong(seat: SeatId, tile: Tile) {
799 const s = this.state;
800 if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return;
801 const p = s.players[seat];
802 const meld = p.melds.find((m) => m.kind === 'pung' && m.tiles[0] === tile);
803 if (!meld) return;
804 this.push(`${p.name} 加槓 ${labelOf(tile)}`, `${p.name} declared an added kong`);
805 p.hand.splice(p.hand.indexOf(tile), 1);
806 meld.kind = 'addkong';
807 meld.tiles = [tile, tile, tile, tile];
808 s.robKongTile = tile;
809
810 // 搶槓 — anyone who can win on this tile may take it.
811 const opts: Partial<Record<SeatId, ClaimOption[]>> = {};
812 for (const other of SEATS) {
813 if (other === seat) continue;
814 const op = s.players[other];
815 if (isWinningHand([...op.hand, tile], op.melds.length) && !this.isSacred(other, tile)) {
816 opts[other] = [{ type: 'hu' }];
817 }
818 }
819 if (Object.keys(opts).length) {
820 s.options = opts;
821 s.responses = {};
822 s.phase = 'robkong';
823 this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`);
824 this.sfx('kong', tile, seat);
825 this.sfx('claimWindow', undefined, seat);
826 } else {
827 this.say(`${p.name} 加槓 added kong`);
828 this.sfx('kong', tile, seat);
829 this.finishAddKong();
830 }
831 this.emit();
832 }
833
834 private finishAddKong() {
835 const s = this.state;
836 s.options = {};
837 s.responses = {};
838 s.robKongTile = null;
839 this.kongReplacement(s.turn);
840 }
841
842 canSelfDrawWin(seat: SeatId): boolean {
843 const s = this.state;
844 if (s.phase !== 'turn' || s.turn !== seat) return false;
845 const p = s.players[seat];
846 return p.hand.length === (5 - p.melds.length) * 3 + 2 && isWinningHand(p.hand, p.melds.length);
847 }
848
849 declareSelfDraw(seat: SeatId) {
850 if (!this.canSelfDrawWin(seat)) return;
851 const s = this.state;
852 const p = s.players[seat];
853 const winTile = s.drawnTile ?? p.hand[p.hand.length - 1];
854 this.push(`${p.name} 自摸`, `${p.name} declared a self-draw`);
855 this.settle([{ seat, winTile, selfDraw: true, robKong: false }], null);
856 }
857
858 /** Tiles a seat is waiting on right now (for the 聽 badge). */
859 tenpai(seat: SeatId): Tile[] {
860 const p = this.state.players[seat];
861 if (p.hand.length !== (5 - p.melds.length) * 3 + 1) return [];
862 return waits(p.hand, p.melds.length);
863 }
864
865 // ---- settlement --------------------------------------------------------
866 /**
867 * 包牌 — feeding the pung that visibly completes a 大三元 or 大四喜 makes
868 * the feeder answer for the whole hand, on the reasoning that by the third
869 * dragon nobody could claim not to have seen it coming.
870 *
871 * Only the seat that fed the *last* of those sets is on the hook, and only
872 * if it came off a discard: a hand that assembled them itself has nobody to
873 * blame.
874 */
875 private liableFor(winner: SeatId, items: TaiItem[]): SeatId | null {
876 const s = this.state;
877 if (!s.rules.liability) return null;
878 const dragons = items.some((i) => i.name === '大三元');
879 const winds = items.some((i) => i.name === '大四喜');
880 if (!dragons && !winds) return null;
881
882 const inSet = dragons
883 ? (t: Tile) => t >= RED && t <= WHITE
884 : (t: Tile) => t >= EAST && t < RED;
885 const melds = s.players[winner].melds.filter((m) => m.kind !== 'chow' && inSet(m.tiles[0]));
886 // One of the sets was still in hand, so it was not handed over.
887 if (melds.length !== (dragons ? 3 : 4)) return null;
888 // `from` is absent on a 暗槓 — that one they made themselves.
889 return melds[melds.length - 1].from ?? null;
890 }
891
892 /** A win waiting to be paid out. */
893 private settle(
894 wins: { seat: SeatId; winTile: Tile; selfDraw: boolean; robKong: boolean }[],
895 loser: SeatId | null,
896 ) {
897 const s = this.state;
898 const { base, taiValue } = s.rules;
899 const extra = dealerTai(s.dealerStreak) * taiValue; // 拉莊, billed to the dealer alone
900 const totalDiscards = s.players.reduce((a, q) => a + q.discards.length, 0);
901 const anyMeld = s.players.some((q) => q.melds.length > 0);
902 const firstGoAround = totalDiscards <= 4 && !anyMeld;
903 const deltas = [0, 0, 0, 0];
904 const results: WinnerResult[] = [];
905
906 for (const w of wins) {
907 const p = s.players[w.seat];
908 // For a self-draw the winning tile is already in hand; drop exactly one copy.
909 const conc = w.selfDraw ? removeOne(p.hand, w.winTile) : p.hand.slice();
910
911 const score = scoreHand({
912 winner: w.seat,
913 dealer: s.dealer,
914 dealerStreak: s.dealerStreak,
915 roundWind: this.roundWind,
916 melds: p.melds,
917 concealed: conc,
918 winTile: w.winTile,
919 selfDraw: w.selfDraw,
920 loser,
921 flowers: p.flowers,
922 robKong: w.robKong,
923 afterKong: s.afterKong && w.selfDraw,
924 lastTile: w.selfDraw && this.wallLeft <= 0,
925 lastDiscard: !w.selfDraw && this.wallLeft <= 0,
926 heavenly: w.selfDraw && w.seat === s.dealer && totalDiscards === 0,
927 earthly: w.selfDraw && w.seat !== s.dealer && firstGoAround,
928 humanly: !w.selfDraw && w.seat !== s.dealer && firstGoAround,
929 });
930
931 const unit = base + score.tai * taiValue;
932 // Who would normally be settling with this winner, and for how much.
933 const bills: [SeatId, number][] = [];
934 if (w.selfDraw) {
935 for (const seat of SEATS) {
936 if (seat === w.seat) continue;
937 bills.push([seat, unit + (seat === s.dealer ? extra : 0)]);
938 }
939 } else if (loser !== null) {
940 bills.push([loser, unit + (loser === s.dealer && w.seat !== s.dealer ? extra : 0)]);
941 }
942
943 const liable = this.liableFor(w.seat, score.items);
944 if (liable !== null && liable !== w.seat) {
945 // 包家 settles the whole hand alone, in everyone else's place.
946 const total = bills.reduce((a, [, n]) => a + n, 0);
947 deltas[liable] -= total;
948 deltas[w.seat] += total;
949 } else {
950 for (const [seat, n] of bills) {
951 deltas[seat] -= n;
952 deltas[w.seat] += n;
953 }
954 }
955
956 results.push({
957 seat: w.seat,
958 selfDraw: w.selfDraw,
959 tai: score.tai,
960 items: score.items,
961 winTile: w.winTile,
962 liable: liable !== null && liable !== w.seat ? liable : null,
963 });
964 }
965
966 for (const seat of SEATS) s.players[seat].score += deltas[seat];
967
968 s.result = { drawGame: false, winners: results, loser, deltas };
969 s.phase = 'handEnd';
970 for (const r of results) {
971 this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`);
972 if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`);
973 }
974 // 一炮多響 puts more than one name to it; the shout comes from the first,
975 // which is the one the table is turned towards anyway.
976 this.sfx(
977 results.some((r) => r.selfDraw) ? 'selfDraw' : 'hu',
978 results[0].winTile,
979 results[0].seat,
980 );
981 this.emit();
982 }
983
984 private endInDraw() {
985 const s = this.state;
986 s.result = {
987 drawGame: true,
988 winners: [],
989 loser: null,
990 deltas: [0, 0, 0, 0],
991 };
992 s.phase = 'handEnd';
993 this.say('流局 draw');
994 this.sfx('drawGame');
995 this.emit();
996 }
997
998 /**
999 * Reset chips, dealer and round, then deal a fresh game. `bots` says which
1000 * seats the computer takes — omitted, everyone at the table is a person.
1001 */
1002 newGame(bots: boolean[] = [false, false, false, false], names?: string[]) {
1003 const s = this.state;
1004 clearSave();
1005 s.bots = bots.slice();
1006 let botN = 0;
1007 for (const seat of SEATS) {
1008 // Seats change hands between games, so the nameplates are redrawn here:
1009 // a chair the computer has just vacated gets its person's name back.
1010 s.players[seat].name = s.bots[seat]
1011 ? BOT_NAMES[botN++ % BOT_NAMES.length]
1012 : names?.[seat] ?? s.players[seat].name;
1013 }
1014 for (const p of s.players) p.score = s.rules.startingScore;
1015 s.dealer = 0;
1016 s.dealerStreak = 0;
1017 s.rotations = 0;
1018 s.handNumber = 0;
1019 s.result = null;
1020 this.startHand();
1021 }
1022
1023 /** Advance the dealer / round and deal the next hand. */
1024 nextHand() {
1025 const s = this.state;
1026 const r = s.result;
1027 if (!r) return;
1028 // 連莊 — a draw, or the dealer being among the winners, keeps the deal.
1029 const dealerKeeps = r.drawGame || r.winners.some((w) => w.seat === s.dealer);
1030 if (dealerKeeps) {
1031 s.dealerStreak++;
1032 } else {
1033 s.dealer = next(s.dealer);
1034 s.dealerStreak = 0;
1035 s.rotations++;
1036 }
1037 if (s.rotations >= 16) {
1038 s.phase = 'gameEnd';
1039 this.emit();
1040 return;
1041 }
1042 this.startHand();
1043 }
1044}
1045
1046/** Is there anything for a person to do in this position, or is it the computer's? */
1047function humanCanAct(s: GameState): boolean {
1048 if (s.phase === 'turn') return !s.bots[s.turn];
1049 if (s.phase === 'claims' || s.phase === 'robkong') {
1050 return SEATS.some((seat) => !s.bots[seat] && s.options[seat] && s.responses[seat] === undefined);
1051 }
1052 return false;
1053}
1054
1055function removeOne(tiles: Tile[], t: Tile): Tile[] {
1056 const out = tiles.slice();
1057 const i = out.indexOf(t);
1058 if (i >= 0) out.splice(i, 1);
1059 return out;
1060}
1061
1062function chowPartners(hand: Tile[], tile: Tile): Tile[][] {
1063 const has = (t: Tile) => hand.includes(t);
1064 const r = tile % 9;
1065 const out: Tile[][] = [];
1066 if (r >= 2 && has(tile - 2) && has(tile - 1)) out.push([tile - 2, tile - 1]);
1067 if (r >= 1 && r <= 7 && has(tile - 1) && has(tile + 1)) out.push([tile - 1, tile + 1]);
1068 if (r <= 6 && has(tile + 1) && has(tile + 2)) out.push([tile + 1, tile + 2]);
1069 return out;
1070}
1071
1072const CLAIM_ZH: Record<ClaimType, string> = { hu: '胡', kong: '槓', pung: '碰', chow: '吃' };
1073const CLAIM_EN: Record<ClaimType, string> = { hu: 'Win', kong: 'Kong', pung: 'Pung', chow: 'Chow' };
1074
1075export function claimName(c: ClaimType): string {
1076 return `${CLAIM_ZH[c]} ${CLAIM_EN[c]}`;
1077}