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