anvilsign in

collin/mahjong

master / src / game / tai.ts
1import { decompose, waits, waitShape, type Decomposition, type TileSet } from './hu';
2import {
3 EAST,
4 NUM_SUITED,
5 RED,
6 WHITE,
7 flowerSeat,
8 flowerSet,
9 isHonor,
10 isSuited,
11 suitOf,
12 type Tile,
13} from './tiles';
14import { meldIsConcealed, meldIsKong, type Meld, type SeatId } from './types';
15
16export interface WinContext {
17 winner: SeatId;
18 dealer: SeatId;
19 /** 連莊 count: 0 on a fresh dealer, 1 after one repeat, ... */
20 dealerStreak: number;
21 /** 圈風 0=東 1=南 2=西 3=北 */
22 roundWind: number;
23 melds: Meld[];
24 /** Concealed tiles, NOT including the winning tile. */
25 concealed: Tile[];
26 winTile: Tile;
27 selfDraw: boolean;
28 /** Discarder, when won off a discard. */
29 loser: SeatId | null;
30 flowers: Tile[];
31 robKong: boolean;
32 afterKong: boolean;
33 lastTile: boolean;
34 lastDiscard: boolean;
35 heavenly: boolean;
36 earthly: boolean;
37 humanly: boolean;
38}
39
40export interface TaiItem {
41 /** The term as it is called at the table. */
42 name: string;
43 /** Plain-English gloss for players still learning the terms. */
44 en: string;
45 tai: number;
46}
47
48export interface TaiResult {
49 tai: number;
50 items: TaiItem[];
51}
52
53/** Score the hand, taking the highest-value interpretation of ambiguous hands. */
54export function scoreHand(ctx: WinContext): TaiResult {
55 const need = 5 - ctx.melds.length;
56 const decs = decompose([...ctx.concealed, ctx.winTile], need);
57 if (decs.length === 0) return { tai: 0, items: [] };
58
59 let best: TaiResult = { tai: -1, items: [] };
60 for (const d of decs) {
61 const r = scoreDecomposition(ctx, d);
62 if (r.tai > best.tai) best = r;
63 }
64 return best;
65}
66
67function seatWindOf(seat: SeatId, dealer: SeatId): number {
68 return (seat - dealer + 4) % 4;
69}
70
71function scoreDecomposition(ctx: WinContext, d: Decomposition): TaiResult {
72 const items: TaiItem[] = [];
73 const add = (name: string, en: string, tai: number) => {
74 if (tai !== 0) items.push({ name, en, tai });
75 };
76
77 // ---- assemble the five sets -------------------------------------------
78 const sets: TileSet[] = ctx.melds.map((m) => ({
79 type: m.kind === 'chow' ? 'chow' : 'pung',
80 tile: m.kind === 'chow' ? Math.min(...m.tiles) : m.tiles[0],
81 concealed: meldIsConcealed(m),
82 kong: meldIsKong(m),
83 }));
84 for (const s of d.sets) sets.push({ ...s });
85
86 // A set completed by someone else's discard is not concealed.
87 if (!ctx.selfDraw) {
88 const i = sets.findIndex(
89 (s) =>
90 s.concealed &&
91 !s.kong &&
92 (s.type === 'pung'
93 ? s.tile === ctx.winTile
94 : ctx.winTile >= s.tile && ctx.winTile <= s.tile + 2),
95 );
96 if (i >= 0) sets[i].concealed = false;
97 }
98
99 const exposedCount = ctx.melds.filter((m) => !meldIsConcealed(m)).length;
100 const fullyConcealed = exposedCount === 0; // 門清 (暗槓 still counts)
101 const seatWind = seatWindOf(ctx.winner, ctx.dealer);
102
103 const allTiles: Tile[] = [];
104 for (const m of ctx.melds) allTiles.push(...m.tiles);
105 allTiles.push(...ctx.concealed, ctx.winTile);
106
107 // ---- flowers ----------------------------------------------------------
108 if (ctx.flowers.length === 8) {
109 add('八仙過海', 'All eight flowers', 8);
110 } else {
111 const own = ctx.flowers.filter((f) => flowerSeat(f) === seatWind).length;
112 if (own) add(`正花 ×${own}`, `Seat flowers ×${own}`, own);
113 for (const set of [0, 1]) {
114 if (ctx.flowers.filter((f) => flowerSet(f) === set).length === 4) {
115 add(set === 0 ? '花槓 (四季)' : '花槓 (四花)', set === 0 ? 'Four seasons' : 'Four flowers', 2);
116 }
117 }
118 }
119
120 // ---- honor sets -------------------------------------------------------
121 const pungs = sets.filter((s) => s.type === 'pung');
122 const dragonPungs = pungs.filter((s) => s.tile >= RED && s.tile <= WHITE).length;
123 const windPungs = pungs.filter((s) => s.tile >= EAST && s.tile < RED);
124 const dragonPair = d.pair >= RED && d.pair <= WHITE;
125 const windPair = d.pair >= EAST && d.pair < RED;
126
127 if (dragonPungs === 3) add('大三元', 'Big three dragons', 8);
128 else if (dragonPungs === 2 && dragonPair) add('小三元', 'Little three dragons', 4);
129 else if (dragonPungs > 0) add(`三元牌 ×${dragonPungs}`, `Dragon pung ×${dragonPungs}`, dragonPungs);
130
131 const windPungCount = windPungs.length;
132 if (windPungCount === 4) add('大四喜', 'Big four winds', 16);
133 else if (windPungCount === 3 && windPair) add('小四喜', 'Little four winds', 8);
134 else {
135 if (windPungs.some((s) => s.tile - EAST === ctx.roundWind)) add('圈風', 'Round wind', 1);
136 if (windPungs.some((s) => s.tile - EAST === seatWind)) add('門風', 'Seat wind', 1);
137 }
138
139 // ---- shape ------------------------------------------------------------
140 const allPungs = sets.every((s) => s.type === 'pung');
141 const allChows = sets.every((s) => s.type === 'chow');
142 if (allPungs) add('碰碰胡', 'All pungs', 4);
143
144 const suits = new Set(allTiles.filter(isSuited).map(suitOf));
145 const hasHonor = allTiles.some(isHonor);
146 if (!allTiles.some(isSuited)) add('字一色', 'All honors', 16);
147 else if (suits.size === 1)
148 add(hasHonor ? '混一色' : '清一色', hasHonor ? 'Half flush' : 'Full flush', hasHonor ? 4 : 8);
149
150 const kinds = new Set(allTiles.map(suitOf));
151 if (kinds.has('m') && kinds.has('s') && kinds.has('p') && kinds.has('wind') && kinds.has('dragon')) {
152 add('五門齊', 'All five types', 2);
153 }
154
155 const concealedPungs = sets.filter((s) => s.type === 'pung' && s.concealed).length;
156 if (concealedPungs === 5) add('五暗刻', 'Five concealed pungs', 8);
157 else if (concealedPungs === 4) add('四暗刻', 'Four concealed pungs', 5);
158 else if (concealedPungs === 3) add('三暗刻', 'Three concealed pungs', 2);
159
160 const pairIsValuable =
161 dragonPair || (windPair && (d.pair - EAST === ctx.roundWind || d.pair - EAST === seatWind));
162 if (allChows && !pairIsValuable && !ctx.selfDraw && !hasHonorSet(sets)) add('平胡', 'All chows', 2);
163
164 // ---- how it was won ---------------------------------------------------
165 if (ctx.heavenly) add('天胡', 'Blessing of heaven', 16);
166 else if (ctx.earthly) add('地胡', 'Blessing of earth', 16);
167 else if (ctx.humanly) add('人胡', 'Blessing of man', 8);
168
169 if (ctx.selfDraw) add('自摸', 'Self-draw', 1);
170 if (fullyConcealed) add('門清', 'Fully concealed', 1);
171 if (fullyConcealed && ctx.selfDraw) add('門清自摸', 'Concealed self-draw', 1);
172 if (!ctx.selfDraw && exposedCount === 5) add('全求人', 'All melded', 2);
173
174 if (ctx.robKong) add('搶槓', 'Robbing the kong', 1);
175 if (ctx.afterKong) add('槓上開花', 'Win off a kong', 1);
176 if (ctx.lastTile) add('海底撈月', 'Last tile of the wall', 1);
177 if (ctx.lastDiscard) add('河底撈魚', 'Last discard', 1);
178
179 const w = waits(ctx.concealed, ctx.melds.length);
180 if (w.length === 1) {
181 const shape = waitShape(ctx.concealed, ctx.winTile, ctx.melds.length);
182 add(shape === 'pair' ? '單釣' : '獨聽', shape === 'pair' ? 'Single pair wait' : 'Sole wait', 1);
183 }
184
185 // ---- dealer -----------------------------------------------------------
186 // 莊家 tai only applies when the dealer is the winner; when the dealer is a
187 // payer the same amount is billed to the dealer alone (see settleHand).
188 if (ctx.winner === ctx.dealer)
189 add(dealerLabel(ctx.dealerStreak), dealerLabelEn(ctx.dealerStreak), dealerTai(ctx.dealerStreak));
190
191 return { tai: items.reduce((a, i) => a + i.tai, 0), items };
192}
193
194function hasHonorSet(sets: TileSet[]): boolean {
195 return sets.some((s) => s.tile >= NUM_SUITED);
196}
197
198/** 莊 1台, 連N拉N → 2N+1台. */
199export function dealerTai(streak: number): number {
200 return 1 + 2 * streak;
201}
202
203export function dealerLabel(streak: number): string {
204 return streak > 0 ? `莊家 連${streak}拉${streak}` : '莊家';
205}
206
207export function dealerLabelEn(streak: number): string {
208 return streak > 0 ? `Dealer, ${streak} in a row` : 'Dealer';
209}