| 1 | import { NO_DANGER, readTable } from './danger'; |
| 2 | import type { Claim, ClaimOption, GameState } from './engine'; |
| 3 | import { shanten, ukeire } from './shanten'; |
| 4 | import { isHonor, isSuited, NUM_BASIC, rankOf, type Tile } from './tiles'; |
| 5 | import type { SeatId } from './types'; |
| 6 | |
| 7 | /** |
| 8 | * The computer players. |
| 9 | * |
| 10 | * One idea, applied everywhere: a hand is judged at its resting size — the |
| 11 | * (5 − melds) × 3 + 1 tiles you hold between a discard and your next draw — |
| 12 | * by its 向聽 first and by how many tiles would advance it second. Every |
| 13 | * decision below is the same comparison. Discard the tile that leaves the best |
| 14 | * resting hand; take a 碰 / 吃 only when the hand it leaves is strictly closer |
| 15 | * to home than the one you already have. |
| 16 | * |
| 17 | * The bot is scrupulous about what it knows. `unseenFor` counts only tiles |
| 18 | * that are face up on the table or in the bot's own hand — it never reads an |
| 19 | * opponent's tiles, and it never looks at the wall. |
| 20 | * |
| 21 | * All of it is pure: state in, decision out, no timers and no mutation. When |
| 22 | * the bots should act, and how long they appear to think about it, is |
| 23 | * `autoplay.ts`'s business. |
| 24 | */ |
| 25 | |
| 26 | /** How many of each basic tile this seat cannot account for yet. */ |
| 27 | export function unseenFor(state: GameState, seat: SeatId): number[] { |
| 28 | const unseen = new Array<number>(NUM_BASIC).fill(4); |
| 29 | const drop = (t: Tile) => { |
| 30 | if (t < NUM_BASIC) unseen[t]--; |
| 31 | }; |
| 32 | for (const t of state.players[seat].hand) drop(t); |
| 33 | for (const p of state.players) { |
| 34 | for (const t of p.discards) drop(t); |
| 35 | for (const m of p.melds) for (const t of m.tiles) drop(t); |
| 36 | } |
| 37 | // The tile currently on the table is in somebody's discard pile already. |
| 38 | return unseen; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Everything a bot is allowed to know about the table, worked out once: what |
| 43 | * is still out there, and what it would cost to throw each tile. |
| 44 | */ |
| 45 | export function botView(state: GameState, seat: SeatId): { unseen: number[]; danger: number[] } { |
| 46 | const unseen = unseenFor(state, seat); |
| 47 | return { unseen, danger: readTable(state, seat, unseen).danger }; |
| 48 | } |
| 49 | |
| 50 | /** What a resting hand is worth: nearer home first, then more ways to improve. */ |
| 51 | interface Value { |
| 52 | shanten: number; |
| 53 | ukeire: number; |
| 54 | } |
| 55 | |
| 56 | function valueOf(hand: Tile[], meldCount: number, unseen: number[]): Value { |
| 57 | const sh = shanten(hand, meldCount); |
| 58 | // A finished hand has nothing left to draw for, and counting it is wasted work. |
| 59 | if (sh < 0) return { shanten: sh, ukeire: 0 }; |
| 60 | return { shanten: sh, ukeire: ukeire(hand, meldCount, unseen).count }; |
| 61 | } |
| 62 | |
| 63 | const better = (a: Value, b: Value) => |
| 64 | a.shanten !== b.shanten ? a.shanten < b.shanten : a.ukeire > b.ukeire; |
| 65 | |
| 66 | /** |
| 67 | * Separates tiles that are equally useless on paper. Prefers to let go of what |
| 68 | * is hardest to build on: a lone honour whose copies are mostly gone is worth |
| 69 | * less than a lone 五萬, which at least has neighbours it might meet. |
| 70 | */ |
| 71 | function dropRank(tile: Tile, hand: Tile[], unseen: number[]): number { |
| 72 | const near = hand.filter((t) => t === tile || (isSuited(tile) && isSuited(t) && Math.abs(t - tile) <= 2 && Math.floor(t / 9) === Math.floor(tile / 9))).length - 1; |
| 73 | let score = near === 0 ? 100 : 0; // nothing in hand relates to it |
| 74 | if (isHonor(tile)) score += 20 + (4 - unseen[tile]) * 6; // and fewer left to pair with |
| 75 | else if (rankOf(tile) === 1 || rankOf(tile) === 9) score += 10; |
| 76 | else score += 5 - Math.abs(rankOf(tile) - 5); |
| 77 | return score; |
| 78 | } |
| 79 | |
| 80 | /** One 向聽 step, in the same currency as everything else on the scoreboard. */ |
| 81 | const SHANTEN_STEP = 100; |
| 82 | /** Past this much 進張 the extra hardly changes how the hand plays. */ |
| 83 | const UKEIRE_CAP = 45; |
| 84 | /** |
| 85 | * What a bot will pay to stay out of the way, by how close its own hand is. |
| 86 | * At 聽牌 it pushes — no danger short of 包牌 is worth breaking a ready hand |
| 87 | * for. Two or three away with somebody live across the table and it will give |
| 88 | * up a whole 向聽 step to throw something safe, which is what folding is. |
| 89 | */ |
| 90 | const RISK_BY_SHANTEN = [40, 160, 320, 420]; |
| 91 | |
| 92 | /** The tile to throw from a hand that is one over its resting size. */ |
| 93 | export function botDiscard( |
| 94 | hand: Tile[], |
| 95 | meldCount: number, |
| 96 | unseen: number[], |
| 97 | danger: number[] = NO_DANGER, |
| 98 | ): Tile { |
| 99 | const choices = [...new Set(hand)]; |
| 100 | const scored = choices.map((t) => ({ |
| 101 | tile: t, |
| 102 | value: valueOf(removeOne(hand, t), meldCount, unseen), |
| 103 | rank: dropRank(t, hand, unseen), |
| 104 | })); |
| 105 | |
| 106 | // How much risk is worth taking is decided by the best the hand can do, not |
| 107 | // by the tile under consideration — otherwise throwing the hand away would |
| 108 | // make itself look cheap. |
| 109 | const closest = Math.min(...scored.map((c) => c.value.shanten)); |
| 110 | const risk = RISK_BY_SHANTEN[Math.min(Math.max(closest, 0), RISK_BY_SHANTEN.length - 1)]; |
| 111 | |
| 112 | let best = scored[0]; |
| 113 | let bestScore = -Infinity; |
| 114 | for (const c of scored) { |
| 115 | const score = |
| 116 | -c.value.shanten * SHANTEN_STEP + |
| 117 | Math.min(c.value.ukeire, UKEIRE_CAP) - |
| 118 | (danger[c.tile] ?? 0) * risk + |
| 119 | c.rank * 0.05; |
| 120 | if (score > bestScore) { |
| 121 | bestScore = score; |
| 122 | best = c; |
| 123 | } |
| 124 | } |
| 125 | return best.tile; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Whether to take the tile on the table, and with what. |
| 130 | * |
| 131 | * 胡 is never turned down. Everything else has to earn itself: melding costs |
| 132 | * the hand its concealment and its flexibility, so a 碰 or 吃 is only worth it |
| 133 | * if the hand that comes out the other side is *strictly* closer to home. A 槓 |
| 134 | * is judged more kindly — it pays 台 and fetches a replacement tile, so |
| 135 | * standing still is good enough. |
| 136 | */ |
| 137 | export function botClaim( |
| 138 | state: GameState, |
| 139 | seat: SeatId, |
| 140 | tile: Tile, |
| 141 | options: ClaimOption[], |
| 142 | ): Claim | 'pass' { |
| 143 | const p = state.players[seat]; |
| 144 | const melds = p.melds.length; |
| 145 | const unseen = unseenFor(state, seat); |
| 146 | |
| 147 | const win = options.find((o) => o.type === 'hu'); |
| 148 | if (win) return { type: 'hu' }; |
| 149 | |
| 150 | const now = valueOf(p.hand, melds, unseen); |
| 151 | let best: { claim: Claim; value: Value } | null = null; |
| 152 | |
| 153 | for (const o of options) { |
| 154 | if (o.type === 'hu') continue; |
| 155 | if (o.type === 'kong') { |
| 156 | // Nothing left to discard afterwards — the replacement tile decides that. |
| 157 | const rest = removeMany(p.hand, tile, 3); |
| 158 | const v = valueOf(rest, melds + 1, unseen); |
| 159 | if (v.shanten <= now.shanten && (best === null || better(v, best.value))) { |
| 160 | best = { claim: { type: 'kong' }, value: v }; |
| 161 | } |
| 162 | continue; |
| 163 | } |
| 164 | const used = o.type === 'pung' ? [tile, tile] : o.with!; |
| 165 | let rest = p.hand; |
| 166 | for (const t of used) rest = removeOne(rest, t); |
| 167 | // A claim is followed by a discard, so judge the best hand it can leave. |
| 168 | const after = bestAfterDiscard(rest, melds + 1, unseen); |
| 169 | if (after.shanten < now.shanten && (best === null || better(after, best.value))) { |
| 170 | best = { claim: { type: o.type, with: o.with }, value: after }; |
| 171 | } |
| 172 | } |
| 173 | return best ? best.claim : 'pass'; |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * 暗槓 / 加槓 on the bot's own turn. Both leave the hand at its resting size |
| 178 | * with one fewer set to find, so they are judged the same way a discard is, |
| 179 | * and taken as long as the hand does not go backwards. |
| 180 | */ |
| 181 | export function botKong( |
| 182 | state: GameState, |
| 183 | seat: SeatId, |
| 184 | concealed: Tile[], |
| 185 | added: Tile[], |
| 186 | ): { kind: 'ankong' | 'addkong'; tile: Tile } | null { |
| 187 | if (concealed.length === 0 && added.length === 0) return null; |
| 188 | const p = state.players[seat]; |
| 189 | const melds = p.melds.length; |
| 190 | const unseen = unseenFor(state, seat); |
| 191 | const keep = bestAfterDiscard(p.hand, melds, unseen); |
| 192 | |
| 193 | let best: { kind: 'ankong' | 'addkong'; tile: Tile; value: Value } | null = null; |
| 194 | for (const t of concealed) { |
| 195 | const v = valueOf(removeMany(p.hand, t, 4), melds + 1, unseen); |
| 196 | if (v.shanten <= keep.shanten && (best === null || better(v, best.value))) { |
| 197 | best = { kind: 'ankong', tile: t, value: v }; |
| 198 | } |
| 199 | } |
| 200 | for (const t of added) { |
| 201 | // The pung is already down; the fourth tile just moves out of hand. |
| 202 | const v = valueOf(removeOne(p.hand, t), melds, unseen); |
| 203 | if (v.shanten <= keep.shanten && (best === null || better(v, best.value))) { |
| 204 | best = { kind: 'addkong', tile: t, value: v }; |
| 205 | } |
| 206 | } |
| 207 | return best ? { kind: best.kind, tile: best.tile } : null; |
| 208 | } |
| 209 | |
| 210 | /** The best resting hand reachable from `hand` by throwing one tile. */ |
| 211 | function bestAfterDiscard(hand: Tile[], meldCount: number, unseen: number[]): Value { |
| 212 | let best: Value | null = null; |
| 213 | for (const t of new Set(hand)) { |
| 214 | const v = valueOf(removeOne(hand, t), meldCount, unseen); |
| 215 | if (best === null || better(v, best)) best = v; |
| 216 | } |
| 217 | return best ?? { shanten: 99, ukeire: 0 }; |
| 218 | } |
| 219 | |
| 220 | function removeOne(tiles: Tile[], t: Tile): Tile[] { |
| 221 | const out = tiles.slice(); |
| 222 | const i = out.indexOf(t); |
| 223 | if (i >= 0) out.splice(i, 1); |
| 224 | return out; |
| 225 | } |
| 226 | |
| 227 | function removeMany(tiles: Tile[], t: Tile, n: number): Tile[] { |
| 228 | let out = tiles; |
| 229 | for (let k = 0; k < n; k++) out = removeOne(out, t); |
| 230 | return out; |
| 231 | } |