anvilsign in

collin/mahjong

master / src / game / hu.ts
1import { NUM_BASIC, NUM_SUITED, toCounts, type Tile } from './tiles';
2
3export type SetType = 'chow' | 'pung';
4
5/** A completed set. `tile` is the pung tile, or the *lowest* tile of a chow. */
6export interface TileSet {
7 type: SetType;
8 tile: Tile;
9 concealed: boolean;
10 kong?: boolean;
11}
12
13export interface Decomposition {
14 sets: TileSet[];
15 pair: Tile;
16}
17
18/** Every way the concealed tiles split into `needSets` sets + one pair. */
19export function decompose(tiles: Tile[], needSets: number): Decomposition[] {
20 const counts = toCounts(tiles);
21 const total = counts.reduce((a, b) => a + b, 0);
22 if (total !== needSets * 3 + 2) return [];
23
24 const out: Decomposition[] = [];
25 const seen = new Set<string>();
26
27 for (let p = 0; p < NUM_BASIC; p++) {
28 if (counts[p] < 2) continue;
29 counts[p] -= 2;
30 const found: TileSet[][] = [];
31 splitSets(counts, needSets, [], found);
32 counts[p] += 2;
33 for (const sets of found) {
34 const key = p + '|' + sets.map((s) => s.type + s.tile).sort().join(',');
35 if (seen.has(key)) continue;
36 seen.add(key);
37 out.push({ sets, pair: p });
38 }
39 }
40 return out;
41}
42
43function splitSets(counts: number[], need: number, acc: TileSet[], out: TileSet[][]) {
44 if (need === 0) {
45 for (const c of counts) if (c !== 0) return;
46 out.push(acc.slice());
47 return;
48 }
49 let i = 0;
50 while (i < NUM_BASIC && counts[i] === 0) i++;
51 if (i === NUM_BASIC) return;
52
53 if (counts[i] >= 3) {
54 counts[i] -= 3;
55 acc.push({ type: 'pung', tile: i, concealed: true });
56 splitSets(counts, need - 1, acc, out);
57 acc.pop();
58 counts[i] += 3;
59 }
60 if (i < NUM_SUITED && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) {
61 counts[i]--;
62 counts[i + 1]--;
63 counts[i + 2]--;
64 acc.push({ type: 'chow', tile: i, concealed: true });
65 splitSets(counts, need - 1, acc, out);
66 acc.pop();
67 counts[i]++;
68 counts[i + 1]++;
69 counts[i + 2]++;
70 }
71}
72
73export function isWinningHand(tiles: Tile[], meldCount: number): boolean {
74 return decompose(tiles, 5 - meldCount).length > 0;
75}
76
77/** Tiles that would complete the hand if drawn/claimed. `tiles` is the concealed hand. */
78export function waits(tiles: Tile[], meldCount: number): Tile[] {
79 const need = 5 - meldCount;
80 if (tiles.length !== need * 3 + 1) return [];
81 const out: Tile[] = [];
82 for (let t = 0; t < NUM_BASIC; t++) {
83 if (decompose([...tiles, t], need).length > 0) out.push(t);
84 }
85 return out;
86}
87
88export type WaitShape = 'pair' | 'edge' | 'closed' | 'open';
89
90/**
91 * How the winning tile was waited on — used for 獨聽 / 單釣.
92 * Only meaningful when the hand has a single wait.
93 */
94export function waitShape(concealedBefore: Tile[], winTile: Tile, meldCount: number): WaitShape {
95 const need = 5 - meldCount;
96 const decs = decompose([...concealedBefore, winTile], need);
97 let best: WaitShape = 'open';
98 const rank = { pair: 3, edge: 2, closed: 2, open: 1 } as const;
99 for (const d of decs) {
100 let shape: WaitShape | null = null;
101 if (d.pair === winTile) {
102 // could be 單釣 only if the pair really needed this tile
103 const c = toCounts(concealedBefore);
104 if (c[winTile] === 1) shape = 'pair';
105 }
106 if (!shape) {
107 for (const s of d.sets) {
108 if (s.type === 'chow') {
109 if (s.tile === winTile && s.tile % 9 === 6) shape = 'edge'; // 7-8 waiting 9? (789 low=7)
110 else if (s.tile + 2 === winTile && s.tile % 9 === 0) shape = 'edge'; // 1-2 waiting 3
111 else if (s.tile + 1 === winTile) shape = 'closed';
112 }
113 }
114 }
115 if (shape && rank[shape] > rank[best]) best = shape;
116 }
117 return best;
118}