anvilsign in

collin/mahjong

1import { NO_DANGER, readTable } from './danger';
2import type { Claim, ClaimOption, GameState } from './engine';
3import { shanten, ukeire } from './shanten';
4import { isHonor, isSuited, NUM_BASIC, rankOf, type Tile } from './tiles';
5import 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. */
27export 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 */
45export 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. */
51interface Value {
52 shanten: number;
53 ukeire: number;
54}
55
56function 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
63const 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 */
71function 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. */
81const SHANTEN_STEP = 100;
82/** Past this much 進張 the extra hardly changes how the hand plays. */
83const 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 */
90const RISK_BY_SHANTEN = [40, 160, 320, 420];
91
92/** The tile to throw from a hand that is one over its resting size. */
93export 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 */
137export 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 */
181export 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. */
211function 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
220function 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
227function 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}