anvilsign in

collin/mahjong

master / src / game / danger.ts
1import type { GameState } from './engine';
2import {
3 EAST,
4 isHonor,
5 isSuited,
6 NUM_BASIC,
7 rankOf,
8 RED,
9 suitOf,
10 WHITE,
11 type Tile,
12} from './tiles';
13import type { Player, SeatId } from './types';
14
15const 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
32export 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 */
44function 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 */
57function 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. */
65const 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. */
68function 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 */
81const TURNS_TO_READY = 10;
82
83export 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. */
135export const NO_DANGER: number[] = new Array<number>(NUM_BASIC).fill(0);