| 1 | import { decompose, waits, waitShape, type Decomposition, type TileSet } from './hu'; |
| 2 | import { |
| 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'; |
| 14 | import { meldIsConcealed, meldIsKong, type Meld, type SeatId } from './types'; |
| 15 | |
| 16 | export 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 | |
| 40 | export 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 | |
| 48 | export interface TaiResult { |
| 49 | tai: number; |
| 50 | items: TaiItem[]; |
| 51 | } |
| 52 | |
| 53 | /** Score the hand, taking the highest-value interpretation of ambiguous hands. */ |
| 54 | export 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 | |
| 67 | function seatWindOf(seat: SeatId, dealer: SeatId): number { |
| 68 | return (seat - dealer + 4) % 4; |
| 69 | } |
| 70 | |
| 71 | function 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 | |
| 194 | function hasHonorSet(sets: TileSet[]): boolean { |
| 195 | return sets.some((s) => s.tile >= NUM_SUITED); |
| 196 | } |
| 197 | |
| 198 | /** 莊 1台, 連N拉N → 2N+1台. */ |
| 199 | export function dealerTai(streak: number): number { |
| 200 | return 1 + 2 * streak; |
| 201 | } |
| 202 | |
| 203 | export function dealerLabel(streak: number): string { |
| 204 | return streak > 0 ? `莊家 連${streak}拉${streak}` : '莊家'; |
| 205 | } |
| 206 | |
| 207 | export function dealerLabelEn(streak: number): string { |
| 208 | return streak > 0 ? `Dealer, ${streak} in a row` : 'Dealer'; |
| 209 | } |