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