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