| 1 | import type { GameState } from './engine'; |
| 2 | import { |
| 3 | EAST, |
| 4 | isHonor, |
| 5 | isSuited, |
| 6 | NUM_BASIC, |
| 7 | rankOf, |
| 8 | RED, |
| 9 | suitOf, |
| 10 | WHITE, |
| 11 | type Tile, |
| 12 | } from './tiles'; |
| 13 | import type { Player, SeatId } from './types'; |
| 14 | |
| 15 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 16 | |
| 17 | /** |
| 18 | * Reading the table. |
| 19 | * |
| 20 | * Everything here comes off the face-up table — discard rows, exposed melds, |
| 21 | * the 過水 locks the nameplates already show, and how much wall is left. No |
| 22 | * opponent's concealed hand is ever touched; a bot that peeked would not be |
| 23 | * playing the same game as the person across from it. |
| 24 | * |
| 25 | * Two questions, answered separately. `threat` asks how close each seat looks |
| 26 | * to being home, which decides how much notice to take of them at all. |
| 27 | * `danger` asks how likely a particular tile is to be the one they are sitting |
| 28 | * on. What the bot does with the answer is `bot.ts`'s business — a hand that is |
| 29 | * one tile from winning pushes through danger the same way a person would. |
| 30 | */ |
| 31 | |
| 32 | export interface TableRead { |
| 33 | /** Per seat, roughly how close to home they look. 0..1, own seat always 0. */ |
| 34 | threat: number[]; |
| 35 | /** Per basic tile, how costly it looks to throw right now. 0 is 現物-safe. */ |
| 36 | danger: number[]; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * How many wait shapes a tile can complete at all. An honour can only ever be |
| 41 | * caught by a pair or a triplet; a 五萬 sits in the middle of every run that |
| 42 | * passes through it, which is why it is the last thing anyone throws late. |
| 43 | */ |
| 44 | function shapeRisk(t: Tile): number { |
| 45 | if (isHonor(t)) return 0.28; |
| 46 | const r = rankOf(t); |
| 47 | if (r === 1 || r === 9) return 0.55; |
| 48 | if (r === 2 || r === 8) return 0.8; |
| 49 | return 1; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Late middle tiles are the tell. Nobody throws 五條 out of a hand that still |
| 54 | * needs shaping — it comes out once the shape is finished and the tile has |
| 55 | * nowhere left to go. |
| 56 | */ |
| 57 | function lateMiddleShare(p: Player): number { |
| 58 | if (p.discards.length < 4) return 0; |
| 59 | const recent = p.discards.slice(-4); |
| 60 | const middle = recent.filter((t) => isSuited(t) && rankOf(t) >= 3 && rankOf(t) <= 7).length; |
| 61 | return middle / recent.length; |
| 62 | } |
| 63 | |
| 64 | /** Pungs and kongs of a kind — the melds that say what a hand is going for. */ |
| 65 | const tripletsOf = (p: Player) => p.melds.filter((m) => m.kind !== 'chow'); |
| 66 | |
| 67 | /** The suit a seat has visibly committed to, if their melds all point one way. */ |
| 68 | function flushSuit(p: Player): string | null { |
| 69 | const suits = new Set( |
| 70 | p.melds.flatMap((m) => m.tiles.filter(isSuited).map((t) => suitOf(t))), |
| 71 | ); |
| 72 | if (p.melds.length < 2 || suits.size !== 1) return null; |
| 73 | return [...suits][0]; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Turns, not tiles. A hand is usually settled inside ten goes each, long before |
| 78 | * the wall looks anywhere near finished, so counting how many times a seat has |
| 79 | * actually played is a far better clock than how much wall is left. |
| 80 | */ |
| 81 | const TURNS_TO_READY = 10; |
| 82 | |
| 83 | export function readTable(state: GameState, seat: SeatId, unseen: number[]): TableRead { |
| 84 | const threat = [0, 0, 0, 0]; |
| 85 | const danger = new Array<number>(NUM_BASIC).fill(0); |
| 86 | |
| 87 | for (const o of SEATS) { |
| 88 | if (o === seat) continue; |
| 89 | const p = state.players[o]; |
| 90 | |
| 91 | const turns = Math.min(1, p.discards.length / TURNS_TO_READY); |
| 92 | let t = 0.16 * p.melds.length + 0.5 * turns + 0.3 * lateMiddleShare(p); |
| 93 | // 過水 is a confession: to be locked out of a tile they had to have been |
| 94 | // able to win on it, which means they were 聽牌 when it went past. |
| 95 | if (state.sacred[o].length > 0) t += 0.3; |
| 96 | threat[o] = Math.min(1, t); |
| 97 | |
| 98 | const flush = flushSuit(p); |
| 99 | const trips = tripletsOf(p); |
| 100 | const dragons = trips.filter((m) => m.tiles[0] >= RED && m.tiles[0] <= WHITE).length; |
| 101 | const winds = trips.filter((m) => m.tiles[0] >= EAST && m.tiles[0] < RED).length; |
| 102 | // 包牌 — hand over the third dragon or the fourth wind and the whole hand |
| 103 | // is billed to you alone. Worth going a long way round to avoid. |
| 104 | const baoDragon = state.rules.liability && dragons === 2; |
| 105 | const baoWind = state.rules.liability && winds === 3; |
| 106 | |
| 107 | for (let tile = 0; tile < NUM_BASIC; tile++) { |
| 108 | // 過水 — they have already passed on winning with this one. |
| 109 | if (state.rules.sacredDiscard && state.sacred[o].includes(tile)) continue; |
| 110 | |
| 111 | let d = shapeRisk(tile); |
| 112 | // They threw it themselves, so it was not their tile when it went down. |
| 113 | // Their hand has moved on since, but it is still the best evidence there is. |
| 114 | if (p.discards.includes(tile)) d *= 0.35; |
| 115 | |
| 116 | // A pair or triplet wait needs live copies; without any, only a run can |
| 117 | // catch this tile — and nothing at all can catch a lone honour. |
| 118 | if (unseen[tile] <= 0) d *= isHonor(tile) ? 0.12 : 0.55; |
| 119 | else if (unseen[tile] === 1) d *= 0.8; |
| 120 | |
| 121 | if (flush !== null) d *= suitOf(tile) === flush ? 1.7 : isHonor(tile) ? 1.25 : 0.6; |
| 122 | const isDragon = tile >= RED && tile <= WHITE; |
| 123 | const isWind = tile >= EAST && tile < RED; |
| 124 | // Honours are cheap to hold on to, and the bill for getting this one |
| 125 | // wrong is the entire hand, so the multiplier is deliberately lopsided. |
| 126 | if ((baoDragon && isDragon) || (baoWind && isWind)) d *= 10; |
| 127 | |
| 128 | danger[tile] = Math.max(danger[tile], threat[o] * d); |
| 129 | } |
| 130 | } |
| 131 | return { threat, danger }; |
| 132 | } |
| 133 | |
| 134 | /** A table nobody is reading — the shape of `danger` when safety is switched off. */ |
| 135 | export const NO_DANGER: number[] = new Array<number>(NUM_BASIC).fill(0); |