anvilsign in

collin/mahjong

1import { describe, expect, it } from 'vitest';
2import { stepBots } from './autoplay';
3import { botClaim, botDiscard, unseenFor } from './bot';
4import { readTable } from './danger';
5import { Game } from './engine';
6import { isWinningHand, waits } from './hu';
7import { shanten, ukeire } from './shanten';
8import { buildWall, makeRng, NUM_BASIC, shuffle, type Tile } from './tiles';
9import type { SeatId } from './types';
10
11/** Four copies of everything, minus nothing — the view from an empty table. */
12const allUnseen = () => new Array<number>(NUM_BASIC).fill(4);
13
14describe('向聽 shanten', () => {
15 it('calls a finished 17-tile hand -1', () => {
16 // 123m 456m 789m 111s 東東東 + 白白
17 const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 33];
18 expect(shanten(h, 0)).toBe(-1);
19 });
20
21 it('calls a 16-tile hand waiting on one tile 0', () => {
22 // the same hand less the second 白 — 單釣白
23 const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
24 expect(shanten(h, 0)).toBe(0);
25 });
26
27 it('agrees with the win check on every tile a ready hand could draw', () => {
28 const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
29 for (let t = 0; t < NUM_BASIC; t++) {
30 expect(isWinningHand([...h, t], 0)).toBe(shanten([...h, t], 0) === -1);
31 }
32 });
33
34 it('counts exposed melds as sets already made', () => {
35 // four melds down, 456m concealed, 單釣白
36 const h: Tile[] = [3, 4, 5, 33];
37 expect(shanten(h, 4)).toBe(0);
38 expect(shanten([3, 4, 5, 33, 33], 4)).toBe(-1);
39 });
40
41 it('sees a hand with nothing in it as far from home', () => {
42 // one of everything, no pair, no run: 萬 1 3 5 7 9 with scattered honours
43 const h: Tile[] = [0, 4, 8, 13, 22, 27, 28, 29, 30, 31, 32, 33, 0, 4, 8, 13];
44 expect(shanten(h, 0)).toBeGreaterThan(2);
45 });
46
47 it('never worsens when a tile is added', () => {
48 const h: Tile[] = [0, 0, 1, 2, 9, 10, 11, 18, 18, 18, 27, 27, 31, 31, 5, 6];
49 const before = shanten(h, 0);
50 for (let t = 0; t < NUM_BASIC; t++) {
51 expect(shanten([...h, t], 0)).toBeLessThanOrEqual(before);
52 }
53 });
54
55 it('names the tiles that would actually advance the hand', () => {
56 // 123m 456m 789m 111s 東東東 白 — only 白 finishes it
57 const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
58 const u = ukeire(h, 0, allUnseen());
59 expect(u.tiles).toEqual([33]);
60 // `unseen` is the caller's own bookkeeping — this one has counted nothing.
61 expect(u.count).toBe(4);
62 });
63});
64
65describe('bot discards', () => {
66 it('throws the tile that is doing nothing', () => {
67 // Everything is connected except the lone 北.
68 const h: Tile[] = [0, 1, 2, 3, 4, 5, 9, 10, 11, 18, 19, 20, 27, 27, 31, 31, 30];
69 expect(botDiscard(h, 0, allUnseen())).toBe(30);
70 });
71
72 it('never breaks a hand that is already ready', () => {
73 // Five sets and two loose honours: whichever goes, the hand stays 聽牌.
74 const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 30];
75 const out = botDiscard(h, 0, allUnseen());
76 expect([33, 30]).toContain(out);
77 expect(shanten(h.filter((_, i) => i !== h.indexOf(out)), 0)).toBe(0);
78 });
79
80 it('keeps the shape with more ways to improve when two discards tie', () => {
81 // 45m (兩面, 8 tiles of 進張) against 13s (嵌張, 4) — the 嵌張 goes.
82 const h: Tile[] = [3, 4, 9, 11, 18, 18, 18, 19, 19, 19, 20, 20, 20, 27, 27, 31, 31];
83 const out = botDiscard(h, 0, allUnseen());
84 expect([9, 11]).toContain(out);
85 });
86});
87
88describe('bot claims', () => {
89 const table = () => {
90 const g = new Game();
91 g.newGame([false, true, true, true]);
92 return g;
93 };
94
95 it('always takes the win', () => {
96 const g = table();
97 const seat: SeatId = 1;
98 const p = g.state.players[seat];
99 p.melds = [];
100 p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
101 const claim = botClaim(g.state, seat, 33, [{ type: 'hu' }, { type: 'pung' }]);
102 expect(claim).toEqual({ type: 'hu' });
103 });
104
105 it('takes a 碰 that turns 一向聽 into 聽牌', () => {
106 const g = table();
107 const seat: SeatId = 1;
108 const p = g.state.players[seat];
109 p.melds = [];
110 // 123m 456m 789m + 一二條 + 一筒對 + 中中 + a loose 北.
111 p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 18, 31, 31, 30];
112 expect(shanten(p.hand, 0)).toBe(1);
113 expect(botClaim(g.state, seat, 31, [{ type: 'pung' }])).toEqual({ type: 'pung' });
114 });
115
116 it('passes on a 碰 that would only narrow its wait', () => {
117 const g = table();
118 const seat: SeatId = 1;
119 const p = g.state.players[seat];
120 p.melds = [];
121 // Four sets and two pairs — already 聽牌 on either of them, and melding
122 // one would throw the other wait away.
123 p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 18, 18, 31, 31];
124 expect(shanten(p.hand, 0)).toBe(0);
125 expect(botClaim(g.state, seat, 31, [{ type: 'pung' }])).toBe('pass');
126 });
127
128 it('passes on a 吃 that would only cost it flexibility', () => {
129 const g = table();
130 const seat: SeatId = 1;
131 const p = g.state.players[seat];
132 p.melds = [];
133 // Five sets down and 單釣白; taking 四筒 off the table changes nothing.
134 p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 18, 19, 20, 33];
135 expect(botClaim(g.state, seat, 21, [{ type: 'chow', with: [19, 20] }])).toBe('pass');
136 });
137
138 it('only counts tiles it can actually see', () => {
139 const g = table();
140 const seat: SeatId = 1;
141 g.state.players[0].discards = [31, 31];
142 const unseen = unseenFor(g.state, seat);
143 // Two 中 on the table, plus however many this seat is holding itself.
144 const own = g.state.players[seat].hand.filter((t) => t === 31).length;
145 expect(unseen[31]).toBe(2 - own);
146 // An opponent's concealed hand is none of the bot's business.
147 g.state.players[2].hand = [31, 31];
148 expect(unseenFor(g.state, seat)[31]).toBe(2 - own);
149 });
150});
151
152describe('a table of computer players', () => {
153 /**
154 * Nobody human at all: every seat a bot, driven flat out with the timers
155 * taken away. If the bots can ever talk themselves into a position none of
156 * them will move from, this is where it shows up.
157 */
158 const runHand = (seed: number) => {
159 const g = new Game();
160 g.newGame([true, true, true, true]);
161 g.startHand(seed);
162 let steps = 0;
163 while (g.state.phase !== 'handEnd' && steps < 4000) {
164 expect(stepBots(g)).toBe(true); // somebody always owes a move
165 steps++;
166 }
167 return { game: g, steps };
168 };
169
170 it('plays complete hands out, and wins a fair share of them', () => {
171 let won = 0;
172 for (let seed = 1; seed <= 30; seed++) {
173 const { game, steps } = runHand(seed);
174 const s = game.state;
175 expect(s.phase).toBe('handEnd');
176 expect(steps).toBeLessThan(4000);
177
178 let tiles = s.wall.length;
179 for (const p of s.players) {
180 tiles += p.hand.length + p.flowers.length + p.discards.length;
181 for (const m of p.melds) tiles += m.tiles.length;
182 }
183 expect(tiles).toBe(144);
184
185 if (!s.result!.drawGame) won++;
186 }
187 // Real tables draw a fair few 16-tile hands, so this is a floor rather than
188 // a target: a bot that never won one would mean the discard choice is not
189 // actually taking the hand anywhere.
190 expect(won).toBeGreaterThan(10);
191 }, 30_000);
192});
193
194describe('向聽 against the win checker', () => {
195 /**
196 * `hu.ts` is the authority on whether a hand is finished, and it works a
197 * different way — an exhaustive split rather than a block count. Random
198 * hands are dealt and the two are held against each other: a hand is 聽牌
199 * exactly when `waits` finds something, and home exactly when `isWinningHand`
200 * says so. Any disagreement is a bug in the fast one.
201 */
202 it('agrees on 聽牌 and on finished hands', () => {
203 const rng = makeRng(20250817);
204 for (let n = 0; n < 400; n++) {
205 const melds = Math.floor(rng() * 5);
206 const hand = shuffle(buildWall(), rng)
207 .filter((t) => t < NUM_BASIC)
208 .slice(0, (5 - melds) * 3 + 1);
209 expect(shanten(hand, melds) <= 0).toBe(waits(hand, melds).length > 0);
210 for (let t = 0; t < NUM_BASIC; t++) {
211 const full = [...hand, t];
212 expect(shanten(full, melds) === -1).toBe(isWinningHand(full, melds));
213 }
214 }
215 }, 30_000);
216});
217
218describe('reading the table', () => {
219 /** A hand under way, with the seats posed by hand rather than dealt. */
220 const table = () => {
221 const g = new Game();
222 g.newGame([false, true, true, true]);
223 g.startHand(7);
224 for (const p of g.state.players) {
225 p.discards = [];
226 p.melds = [];
227 }
228 return g;
229 };
230
231 it('takes a seat more seriously the longer it has been playing', () => {
232 const g = table();
233 const early = readTable(g.state, 0, unseenFor(g.state, 0)).threat[1];
234 g.state.players[1].discards = [0, 1, 2, 9, 10, 11, 18, 19, 20, 21];
235 const late = readTable(g.state, 0, unseenFor(g.state, 0)).threat[1];
236 expect(late).toBeGreaterThan(early);
237 });
238
239 it('treats a tile an opponent threw themselves as safer than a fresh one', () => {
240 const g = table();
241 // Two middle tiles of the same rank; seat 1 has already let one of them go.
242 g.state.players[1].discards = [4, 4, 4, 4, 4, 4];
243 const { danger } = readTable(g.state, 0, unseenFor(g.state, 0));
244 expect(danger[4]).toBeLessThan(danger[13]);
245 });
246
247 it('treats 過水 as the confession it is', () => {
248 const g = table();
249 const plain = readTable(g.state, 0, unseenFor(g.state, 0));
250 g.state.sacred[1] = [13];
251 const read = readTable(g.state, 0, unseenFor(g.state, 0));
252 // Locked out of winning on 五條, so it costs nothing to throw at that seat —
253 // but the lock only exists because they were 聽牌 when it went past.
254 expect(read.danger[13]).toBe(0);
255 expect(read.threat[1]).toBeGreaterThan(plain.threat[1]);
256 });
257
258 it('rates a lone honour below a middle tile, and a dead one below that', () => {
259 const g = table();
260 g.state.players[1].discards = [0, 1, 2, 9, 10, 11];
261 const unseen = unseenFor(g.state, 0);
262 const { danger } = readTable(g.state, 0, unseen);
263 expect(danger[30]).toBeLessThan(danger[13]); // 北 against 五條
264 // Every 白 accounted for: nobody can be waiting to pair or pung one.
265 const allWhiteSeen = unseen.slice();
266 allWhiteSeen[33] = 0;
267 expect(readTable(g.state, 0, allWhiteSeen).danger[33]).toBeLessThan(danger[30]);
268 });
269
270 it('gives the third dragon a very wide berth', () => {
271 const g = table();
272 g.state.players[1].discards = [0, 1, 2, 9, 10, 11];
273 g.state.players[1].melds = [
274 { kind: 'pung', tiles: [31, 31, 31], from: 0 },
275 { kind: 'pung', tiles: [32, 32, 32], from: 0 },
276 ];
277 const { danger } = readTable(g.state, 0, unseenFor(g.state, 0));
278 // 白 completes 大三元, and under 包牌 the feeder answers for the whole hand
279 // — so it has to read as worse than the most dangerous ordinary tile going.
280 expect(danger[33]).toBeGreaterThan(danger[13] * 2);
281 expect(danger[33]).toBeGreaterThan(Math.max(...danger.filter((_, i) => i < 31)));
282 });
283
284 it('will break its own shape to get out of the way, but not a ready hand', () => {
285 const unseen = new Array<number>(NUM_BASIC).fill(4);
286 const danger = new Array<number>(NUM_BASIC).fill(0.9);
287 danger[30] = 0; // 北 is 現物
288
289 // Far from home: the safe tile is worth more than the shape.
290 const scrappy: Tile[] = [0, 2, 4, 9, 11, 13, 18, 20, 22, 27, 28, 29, 31, 32, 33, 5, 30];
291 expect(botDiscard(scrappy, 0, unseen, danger)).toBe(30);
292
293 // 聽牌 on 白: nothing on the table is worth giving that up for.
294 const ready: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 30];
295 const out = botDiscard(ready, 0, unseen, danger);
296 expect(shanten(ready.filter((_, i) => i !== ready.indexOf(out)), 0)).toBe(0);
297 });
298});
299
300describe('流局', () => {
301 /**
302 * The draw panel turns the hands over: who was 聽牌 and on what. It reads
303 * `tenpai`, which only answers for a hand at its resting size — so if a draw
304 * ever left somebody holding an odd number of tiles the panel would quietly
305 * report an empty table instead of the reveal.
306 */
307 it('leaves every hand readable, with waits to show', () => {
308 let draws = 0;
309 let ready = 0;
310 for (let seed = 1; seed <= 200 && draws < 6; seed++) {
311 const g = new Game();
312 g.newGame([true, true, true, true]);
313 g.startHand(seed);
314 let steps = 0;
315 while (g.state.phase !== 'handEnd' && steps++ < 4000) stepBots(g);
316 if (!g.state.result!.drawGame) continue;
317 draws++;
318 for (const seat of [0, 1, 2, 3] as SeatId[]) {
319 const p = g.state.players[seat];
320 expect(p.hand.length).toBe((5 - p.melds.length) * 3 + 1);
321 if (g.tenpai(seat).length > 0) ready++;
322 }
323 }
324 expect(draws).toBeGreaterThan(0);
325 // Hands that ran the wall out are usually close; an all-empty reveal would
326 // mean the panel had nothing to say.
327 expect(ready).toBeGreaterThan(0);
328 }, 60_000);
329});