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