| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { stepBots } from './autoplay'; |
| 3 | import { botClaim, botDiscard, unseenFor } from './bot'; |
| 4 | import { readTable } from './danger'; |
| 5 | import { Game } from './engine'; |
| 6 | import { isWinningHand, waits } from './hu'; |
| 7 | import { shanten, ukeire } from './shanten'; |
| 8 | import { buildWall, makeRng, NUM_BASIC, shuffle, type Tile } from './tiles'; |
| 9 | import type { SeatId } from './types'; |
| 10 | |
| 11 | /** Four copies of everything, minus nothing — the view from an empty table. */ |
| 12 | const allUnseen = () => new Array<number>(NUM_BASIC).fill(4); |
| 13 | |
| 14 | describe('向聽 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 | |
| 65 | describe('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 | |
| 88 | describe('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 | |
| 152 | describe('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 | |
| 194 | describe('向聽 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 | |
| 218 | describe('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 | |
| 300 | describe('流局', () => { |
| 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 | }); |