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