| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { Game } from './engine'; |
| 3 | import { decompose, isWinningHand, waits } from './hu'; |
| 4 | import { scoreHand } from './tai'; |
| 5 | import { buildWall, NUM_BASIC, type Tile } from './tiles'; |
| 6 | import { DEFAULT_RULES, type Meld, type SeatId } from './types'; |
| 7 | |
| 8 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 9 | |
| 10 | /** |
| 11 | * Force a seat one tile from home: four pungs down and `hand` concealed, so |
| 12 | * the hand turns on a single tile. Tile conservation goes out of the window, |
| 13 | * which is fine — the tests that care about it deal properly. |
| 14 | */ |
| 15 | function setUpReadyHand(game: Game, seat: SeatId, hand: Tile[]) { |
| 16 | const p = game.state.players[seat]; |
| 17 | const from = ((seat + 1) % 4) as SeatId; |
| 18 | p.melds = [9, 12, 15, 18].map( |
| 19 | (t): Meld => ({ kind: 'pung', tiles: [t, t, t], from }), |
| 20 | ); |
| 21 | p.hand = [...hand]; |
| 22 | } |
| 23 | |
| 24 | describe('wall', () => { |
| 25 | it('has 144 tiles with the right multiplicities', () => { |
| 26 | const w = buildWall(); |
| 27 | expect(w.length).toBe(144); |
| 28 | for (let t = 0; t < NUM_BASIC; t++) expect(w.filter((x) => x === t).length).toBe(4); |
| 29 | for (let t = NUM_BASIC; t < 42; t++) expect(w.filter((x) => x === t).length).toBe(1); |
| 30 | }); |
| 31 | }); |
| 32 | |
| 33 | describe('hu detection', () => { |
| 34 | it('accepts a 17-tile hand of five sets and a pair', () => { |
| 35 | // 123m 456m 789m 111s 東東東 + 白白 → 5 sets + pair |
| 36 | const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 33]; |
| 37 | expect(isWinningHand(h, 0)).toBe(true); |
| 38 | }); |
| 39 | |
| 40 | it('rejects a hand one tile short of a set', () => { |
| 41 | const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 28, 33, 33]; |
| 42 | expect(isWinningHand(h, 0)).toBe(false); |
| 43 | }); |
| 44 | |
| 45 | it('works with melds already exposed', () => { |
| 46 | const h: Tile[] = [0, 1, 2, 3, 4, 5, 33, 33]; // 2 sets + pair, 3 melds down |
| 47 | expect(isWinningHand(h, 3)).toBe(true); |
| 48 | }); |
| 49 | |
| 50 | it('finds both readings of 111222333', () => { |
| 51 | const d = decompose([0, 0, 0, 1, 1, 1, 2, 2, 2, 33, 33], 3); |
| 52 | const kinds = d.map((x) => x.sets.map((s) => s.type).sort().join('')); |
| 53 | expect(kinds).toContain('pungpungpung'); |
| 54 | expect(kinds).toContain('chowchowchow'); |
| 55 | }); |
| 56 | |
| 57 | it('computes waits', () => { |
| 58 | const h: Tile[] = [0, 1, 2, 3, 4, 5, 33]; // 2 melds down, waiting to pair 白 |
| 59 | expect(waits(h, 3)).toEqual([33]); |
| 60 | }); |
| 61 | }); |
| 62 | |
| 63 | describe('scoring', () => { |
| 64 | const baseCtx = { |
| 65 | winner: 0 as SeatId, |
| 66 | dealer: 1 as SeatId, |
| 67 | dealerStreak: 0, |
| 68 | roundWind: 0, |
| 69 | melds: [], |
| 70 | concealed: [] as Tile[], |
| 71 | winTile: 0, |
| 72 | selfDraw: false, |
| 73 | loser: 1 as SeatId, |
| 74 | flowers: [] as Tile[], |
| 75 | robKong: false, |
| 76 | afterKong: false, |
| 77 | lastTile: false, |
| 78 | lastDiscard: false, |
| 79 | heavenly: false, |
| 80 | earthly: false, |
| 81 | humanly: false, |
| 82 | }; |
| 83 | |
| 84 | it('scores 清一色 + 碰碰胡', () => { |
| 85 | // 111m 222m 333m 444m 555m + 66m, all concealed, won on a discard |
| 86 | const concealed: Tile[] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5, 5]; |
| 87 | const r = scoreHand({ ...baseCtx, concealed, winTile: 4 }); |
| 88 | const names = r.items.map((i) => i.name); |
| 89 | expect(names).toContain('清一色'); |
| 90 | expect(names).toContain('碰碰胡'); |
| 91 | expect(names).toContain('門清'); |
| 92 | }); |
| 93 | |
| 94 | it('scores 大三元', () => { |
| 95 | const concealed: Tile[] = [31, 31, 31, 32, 32, 32, 33, 33, 0, 1, 2, 3, 4, 5, 9, 9]; |
| 96 | const r = scoreHand({ ...baseCtx, concealed, winTile: 33 }); |
| 97 | expect(r.items.map((i) => i.name)).toContain('大三元'); |
| 98 | }); |
| 99 | |
| 100 | it('counts seat flowers', () => { |
| 101 | const concealed: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33]; |
| 102 | // winner is seat 0, dealer is seat 1 → seat wind index 3 → 冬(37) and 竹(41) |
| 103 | const r = scoreHand({ ...baseCtx, concealed, winTile: 33, flowers: [37, 41] }); |
| 104 | expect(r.items.find((i) => i.name.startsWith('正花'))?.tai).toBe(2); |
| 105 | }); |
| 106 | }); |
| 107 | |
| 108 | /** |
| 109 | * Plays whole hands with a simple bot to shake out state-machine bugs. |
| 110 | * `hurry` exercises the non-blocking path: at most one seat answers a discard |
| 111 | * before the next player draws and closes the window on everyone else. |
| 112 | */ |
| 113 | function playRandomHand(game: Game, rng: () => number, maxSteps = 4000, hurry = false) { |
| 114 | let steps = 0; |
| 115 | while (game.state.phase !== 'handEnd' && steps++ < maxSteps) { |
| 116 | const s = game.state; |
| 117 | if (s.phase === 'turn') { |
| 118 | const seat = s.turn; |
| 119 | if (game.canSelfDrawWin(seat)) { |
| 120 | game.declareSelfDraw(seat); |
| 121 | continue; |
| 122 | } |
| 123 | const ak = game.concealedKongs(seat); |
| 124 | if (ak.length && rng() < 0.5) { |
| 125 | game.declareConcealedKong(seat, ak[0]); |
| 126 | continue; |
| 127 | } |
| 128 | const gk = game.addedKongs(seat); |
| 129 | if (gk.length && rng() < 0.5) { |
| 130 | game.declareAddedKong(seat, gk[0]); |
| 131 | continue; |
| 132 | } |
| 133 | const hand = s.players[seat].hand; |
| 134 | game.discard(seat, hand[Math.floor(rng() * hand.length)]); |
| 135 | } else if (s.phase === 'claims' || s.phase === 'robkong') { |
| 136 | const seat = (Object.keys(s.options) as unknown as number[]) |
| 137 | .map(Number) |
| 138 | .find((k) => s.responses[k as SeatId] === undefined) as SeatId | undefined; |
| 139 | if (seat === undefined) throw new Error('claims phase with no pending seat'); |
| 140 | const opts = s.options[seat]!; |
| 141 | const hu = opts.find((o) => o.type === 'hu'); |
| 142 | if (hu) game.respond(seat, { type: 'hu' }); |
| 143 | else if (rng() < 0.35) game.respond(seat, { type: opts[0].type, with: opts[0].with }); |
| 144 | else if (hurry) game.resolveNow(); |
| 145 | else game.respond(seat, 'pass'); |
| 146 | } else { |
| 147 | break; |
| 148 | } |
| 149 | } |
| 150 | if (steps >= maxSteps) throw new Error('hand did not terminate'); |
| 151 | } |
| 152 | |
| 153 | describe('saving', () => { |
| 154 | it('round-trips the whole game through JSON', () => { |
| 155 | let seed = 555; |
| 156 | const rng = () => { |
| 157 | seed = (seed * 1103515245 + 12345) & 0x7fffffff; |
| 158 | return seed / 0x7fffffff; |
| 159 | }; |
| 160 | const game = new Game(); |
| 161 | game.startHand(99); |
| 162 | // Play part of a hand so melds, flowers and discards are all populated. |
| 163 | for (let i = 0; i < 40 && game.state.phase !== 'handEnd'; i++) { |
| 164 | const s = game.state; |
| 165 | if (s.phase === 'turn') { |
| 166 | const hand = s.players[s.turn].hand; |
| 167 | game.discard(s.turn, hand[Math.floor(rng() * hand.length)]); |
| 168 | } else { |
| 169 | game.resolveNow(); |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | const clone = JSON.parse(JSON.stringify(game.state)); |
| 174 | expect(clone).toEqual(game.state); |
| 175 | |
| 176 | // A restored game keeps playing from exactly where it left off. |
| 177 | const resumed = new Game(); |
| 178 | resumed.restore(clone); |
| 179 | expect(resumed.state.turn).toBe(game.state.turn); |
| 180 | expect(resumed.state.players[0].hand).toEqual(game.state.players[0].hand); |
| 181 | if (resumed.state.phase === 'turn') { |
| 182 | const hand = resumed.state.players[resumed.state.turn].hand; |
| 183 | const before = hand.length; |
| 184 | resumed.discard(resumed.state.turn, hand[0]); |
| 185 | expect(resumed.state.players.some((p) => p.discards.length > 0)).toBe(true); |
| 186 | expect(before).toBeGreaterThan(0); |
| 187 | } |
| 188 | }); |
| 189 | }); |
| 190 | |
| 191 | describe('full hands', () => { |
| 192 | it('lets the next player close the claim window without waiting', () => { |
| 193 | let seed = 4242; |
| 194 | const rng = () => { |
| 195 | seed = (seed * 1103515245 + 12345) & 0x7fffffff; |
| 196 | return seed / 0x7fffffff; |
| 197 | }; |
| 198 | const game = new Game(); |
| 199 | for (let h = 0; h < 60; h++) { |
| 200 | game.startHand(h * 104729 + 11); |
| 201 | playRandomHand(game, rng, 4000, true); |
| 202 | const s = game.state; |
| 203 | expect(s.phase).toBe('handEnd'); |
| 204 | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); |
| 205 | expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400); |
| 206 | game.nextHand(); |
| 207 | if (game.state.phase === 'gameEnd') break; |
| 208 | } |
| 209 | }); |
| 210 | |
| 211 | it('honours a claim already declared when the window is closed', () => { |
| 212 | const game = new Game(); |
| 213 | game.startHand(7); |
| 214 | const s = game.state; |
| 215 | // Give the seat to the dealer's right a pair to pung with. |
| 216 | const victim = ((s.dealer + 2) % 4) as SeatId; |
| 217 | const tile = s.players[s.dealer].hand[0]; |
| 218 | s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)]; |
| 219 | game.discard(s.dealer, tile); |
| 220 | expect(s.phase).toBe('claims'); |
| 221 | game.respond(victim, { type: 'pung' }); |
| 222 | // The other seats never answer; the next player draws anyway. |
| 223 | game.resolveNow(); |
| 224 | expect(s.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true); |
| 225 | expect(s.turn).toBe(victim); |
| 226 | }); |
| 227 | |
| 228 | it('takes back a mis-tapped discard', () => { |
| 229 | const game = new Game(); |
| 230 | game.startHand(11); |
| 231 | const s = game.state; |
| 232 | const before = JSON.parse(JSON.stringify(s)); |
| 233 | expect(game.undoable()).toBeNull(); |
| 234 | |
| 235 | game.discard(s.dealer, s.players[s.dealer].hand[0]); |
| 236 | expect(game.undoable()).not.toBeNull(); |
| 237 | game.undo(); |
| 238 | |
| 239 | expect(game.state).toEqual(before); |
| 240 | expect(game.undoable()).toBeNull(); |
| 241 | }); |
| 242 | |
| 243 | it('takes back a pass, handing the claim buttons back', () => { |
| 244 | const game = new Game(); |
| 245 | game.startHand(7); |
| 246 | const s = game.state; |
| 247 | const victim = ((s.dealer + 2) % 4) as SeatId; |
| 248 | const tile = s.players[s.dealer].hand[0]; |
| 249 | s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)]; |
| 250 | game.discard(s.dealer, tile); |
| 251 | |
| 252 | game.respond(victim, 'pass'); |
| 253 | expect(game.state.responses[victim]).toBe('pass'); |
| 254 | game.undo(); |
| 255 | expect(game.state.responses[victim]).toBeUndefined(); |
| 256 | expect(game.state.options[victim]).toBeTruthy(); |
| 257 | |
| 258 | // ...and the claim can then be made for real. |
| 259 | game.respond(victim, { type: 'pung' }); |
| 260 | game.resolveNow(); |
| 261 | expect(game.state.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true); |
| 262 | }); |
| 263 | |
| 264 | it('caps the undo stack and forgets the previous hand', () => { |
| 265 | const game = new Game(); |
| 266 | game.startHand(3); |
| 267 | for (let i = 0; i < 30; i++) { |
| 268 | const s = game.state; |
| 269 | if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]); |
| 270 | else game.resolveNow(); |
| 271 | } |
| 272 | let depth = 0; |
| 273 | while (game.undoable()) { |
| 274 | game.undo(); |
| 275 | depth++; |
| 276 | } |
| 277 | expect(depth).toBe(20); |
| 278 | |
| 279 | game.startHand(4); |
| 280 | expect(game.undoable()).toBeNull(); |
| 281 | }); |
| 282 | |
| 283 | it('過水 locks a declined win out until the next draw', () => { |
| 284 | const game = new Game(); |
| 285 | game.startHand(21); |
| 286 | const s = game.state; |
| 287 | // Put a seat one tile from home: four melds down, 123m + 白白 in hand, |
| 288 | // so it wins on 一萬 or 四萬. |
| 289 | const victim = ((s.dealer + 2) % 4) as SeatId; |
| 290 | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 |
| 291 | const tile = 3; // 四萬 |
| 292 | |
| 293 | game.state.players[s.dealer].hand = [tile, ...game.state.players[s.dealer].hand.slice(1)]; |
| 294 | game.discard(s.dealer, tile); |
| 295 | expect(s.options[victim]?.some((o) => o.type === 'hu')).toBe(true); |
| 296 | game.respond(victim, 'pass'); |
| 297 | game.resolveNow(); |
| 298 | expect(game.sacredTiles(victim)).toContain(tile); |
| 299 | |
| 300 | // Same tile again from the next seat round: no 胡 on offer this time. |
| 301 | const other = s.turn; |
| 302 | expect(other).not.toBe(victim); |
| 303 | game.state.players[other].hand = [tile, ...game.state.players[other].hand.slice(1)]; |
| 304 | game.discard(other, tile); |
| 305 | expect(s.options[victim]?.some((o) => o.type === 'hu')).toBeFalsy(); |
| 306 | }); |
| 307 | |
| 308 | it('過水 lifts once the locked seat draws again', () => { |
| 309 | const game = new Game(); |
| 310 | game.startHand(21); |
| 311 | const s = game.state; |
| 312 | const victim = ((s.dealer + 2) % 4) as SeatId; |
| 313 | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 |
| 314 | s.sacred[victim] = [3]; |
| 315 | |
| 316 | // Walk the table round until the locked seat draws. |
| 317 | for (let i = 0; i < 40 && !(s.phase === 'turn' && s.turn === victim); i++) { |
| 318 | if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]); |
| 319 | else game.resolveNow(); |
| 320 | } |
| 321 | expect(s.turn).toBe(victim); |
| 322 | expect(game.sacredTiles(victim)).toEqual([]); |
| 323 | }); |
| 324 | |
| 325 | it('過水 can be switched off, or lifted by a claim', () => { |
| 326 | for (const clearedByClaim of [false, true]) { |
| 327 | const game = new Game(undefined, { ...DEFAULT_RULES, sacredClearedByClaim: clearedByClaim }); |
| 328 | game.startHand(21); |
| 329 | const s = game.state; |
| 330 | const victim = ((s.dealer + 1) % 4) as SeatId; // to the discarder's left, so it can chow |
| 331 | setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬 |
| 332 | s.sacred[victim] = [3]; |
| 333 | game.state.players[s.dealer].hand = [3, ...game.state.players[s.dealer].hand.slice(1)]; |
| 334 | game.discard(s.dealer, 3); |
| 335 | game.respond(victim, { type: 'chow', with: [1, 2] }); |
| 336 | game.resolveNow(); |
| 337 | expect(game.sacredTiles(victim)).toEqual(clearedByClaim ? [] : [3]); |
| 338 | } |
| 339 | |
| 340 | const off = new Game(undefined, { ...DEFAULT_RULES, sacredDiscard: false }); |
| 341 | off.startHand(21); |
| 342 | const seat = ((off.state.dealer + 2) % 4) as SeatId; |
| 343 | off.state.sacred[seat] = [3]; |
| 344 | expect(off.sacredTiles(seat)).toEqual([]); |
| 345 | }); |
| 346 | |
| 347 | it('一炮多響 pays every seat that calls on the tile', () => { |
| 348 | const game = new Game(undefined, { ...DEFAULT_RULES, multipleWinners: true }); |
| 349 | game.startHand(41); |
| 350 | const s = game.state; |
| 351 | const a = ((s.dealer + 1) % 4) as SeatId; |
| 352 | const b = ((s.dealer + 2) % 4) as SeatId; |
| 353 | setUpReadyHand(game, a, [1, 2, 33, 33]); |
| 354 | setUpReadyHand(game, b, [1, 2, 32, 32]); |
| 355 | s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)]; |
| 356 | |
| 357 | game.discard(s.dealer, 3); |
| 358 | game.respond(a, { type: 'hu' }); |
| 359 | // The nearer seat does not get to close the door on the further one. |
| 360 | expect(s.phase).toBe('claims'); |
| 361 | game.respond(b, { type: 'hu' }); |
| 362 | |
| 363 | const r = s.result!; |
| 364 | expect(r.winners.map((w) => w.seat).sort()).toEqual([a, b].sort()); |
| 365 | expect(r.loser).toBe(s.dealer); |
| 366 | expect(r.deltas[a]).toBeGreaterThan(0); |
| 367 | expect(r.deltas[b]).toBeGreaterThan(0); |
| 368 | expect(r.deltas[s.dealer]).toBe(-(r.deltas[a] + r.deltas[b])); |
| 369 | expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0); |
| 370 | }); |
| 371 | |
| 372 | it('awards the discard to the nearest caller when 一炮多響 is off', () => { |
| 373 | const game = new Game(); |
| 374 | game.startHand(41); |
| 375 | const s = game.state; |
| 376 | const a = ((s.dealer + 1) % 4) as SeatId; |
| 377 | const b = ((s.dealer + 2) % 4) as SeatId; |
| 378 | setUpReadyHand(game, a, [1, 2, 33, 33]); |
| 379 | setUpReadyHand(game, b, [1, 2, 32, 32]); |
| 380 | s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)]; |
| 381 | |
| 382 | game.discard(s.dealer, 3); |
| 383 | game.respond(b, { type: 'hu' }); |
| 384 | game.respond(a, { type: 'hu' }); |
| 385 | expect(s.result!.winners.map((w) => w.seat)).toEqual([a]); |
| 386 | }); |
| 387 | |
| 388 | it('包牌 — whoever fed the third dragon answers for the whole hand', () => { |
| 389 | for (const liability of [true, false]) { |
| 390 | const game = new Game(undefined, { ...DEFAULT_RULES, liability }); |
| 391 | game.startHand(31); |
| 392 | const s = game.state; |
| 393 | const winner = ((s.dealer + 1) % 4) as SeatId; |
| 394 | const feeder = ((s.dealer + 2) % 4) as SeatId; |
| 395 | const other = ((s.dealer + 3) % 4) as SeatId; |
| 396 | |
| 397 | const p = s.players[winner]; |
| 398 | p.melds = [ |
| 399 | { kind: 'pung', tiles: [31, 31, 31], from: other }, // 中 |
| 400 | { kind: 'pung', tiles: [9, 9, 9], from: other }, |
| 401 | { kind: 'pung', tiles: [32, 32, 32], from: other }, // 發 |
| 402 | { kind: 'pung', tiles: [33, 33, 33], from: feeder }, // 白 — the third dragon |
| 403 | ]; |
| 404 | p.hand = [1, 2, 5, 5, 3]; |
| 405 | s.turn = winner; |
| 406 | s.phase = 'turn'; |
| 407 | s.drawnTile = 3; |
| 408 | |
| 409 | game.declareSelfDraw(winner); |
| 410 | const r = s.result!; |
| 411 | expect(r.winners[0].items.map((i) => i.name)).toContain('大三元'); |
| 412 | expect(r.winners[0].liable).toBe(liability ? feeder : null); |
| 413 | if (liability) { |
| 414 | // Everyone else is out of it; the feeder covers all three shares. |
| 415 | expect(r.deltas[other]).toBe(0); |
| 416 | expect(r.deltas[s.dealer]).toBe(0); |
| 417 | expect(r.deltas[feeder]).toBe(-r.deltas[winner]); |
| 418 | } else { |
| 419 | expect(r.deltas[other]).toBeLessThan(0); |
| 420 | expect(r.deltas[feeder]).toBeLessThan(0); |
| 421 | } |
| 422 | expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0); |
| 423 | } |
| 424 | }); |
| 425 | |
| 426 | it('lets 包牌 go when the third dragon came from the winner’s own hand', () => { |
| 427 | const game = new Game(); |
| 428 | game.startHand(31); |
| 429 | const s = game.state; |
| 430 | const winner = ((s.dealer + 1) % 4) as SeatId; |
| 431 | const other = ((s.dealer + 3) % 4) as SeatId; |
| 432 | const p = s.players[winner]; |
| 433 | p.melds = [ |
| 434 | { kind: 'pung', tiles: [31, 31, 31], from: other }, |
| 435 | { kind: 'pung', tiles: [9, 9, 9], from: other }, |
| 436 | { kind: 'pung', tiles: [32, 32, 32], from: other }, |
| 437 | { kind: 'ankong', tiles: [33, 33, 33, 33] }, // 暗槓, nobody handed it over |
| 438 | ]; |
| 439 | p.hand = [1, 2, 5, 5, 3]; |
| 440 | s.turn = winner; |
| 441 | s.phase = 'turn'; |
| 442 | s.drawnTile = 3; |
| 443 | |
| 444 | game.declareSelfDraw(winner); |
| 445 | expect(s.result!.winners[0].items.map((i) => i.name)).toContain('大三元'); |
| 446 | expect(s.result!.winners[0].liable).toBeNull(); |
| 447 | }); |
| 448 | |
| 449 | it('keeps the chips zero-sum under every house rule', () => { |
| 450 | const variants = [ |
| 451 | { multipleWinners: true }, |
| 452 | { multipleWinners: true, sacredDiscard: false }, |
| 453 | { liability: false, sacredClearedByClaim: true }, |
| 454 | ]; |
| 455 | for (const [v, patch] of variants.entries()) { |
| 456 | let seed = 900 + v; |
| 457 | const rng = () => { |
| 458 | seed = (seed * 1103515245 + 12345) & 0x7fffffff; |
| 459 | return seed / 0x7fffffff; |
| 460 | }; |
| 461 | const game = new Game(undefined, { ...DEFAULT_RULES, ...patch }); |
| 462 | for (let h = 0; h < 30; h++) { |
| 463 | game.startHand(h * 7919 + v); |
| 464 | playRandomHand(game, rng); |
| 465 | const s = game.state; |
| 466 | expect(s.phase).toBe('handEnd'); |
| 467 | expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400); |
| 468 | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); |
| 469 | game.nextHand(); |
| 470 | if (game.state.phase === 'gameEnd') break; |
| 471 | } |
| 472 | } |
| 473 | }); |
| 474 | |
| 475 | it('plays 200 random hands without corrupting state', () => { |
| 476 | let seed = 12345; |
| 477 | const rng = () => { |
| 478 | seed = (seed * 1103515245 + 12345) & 0x7fffffff; |
| 479 | return seed / 0x7fffffff; |
| 480 | }; |
| 481 | const game = new Game(); |
| 482 | let wins = 0; |
| 483 | let draws = 0; |
| 484 | |
| 485 | for (let h = 0; h < 200; h++) { |
| 486 | game.startHand(h * 7919 + 3); |
| 487 | playRandomHand(game, rng); |
| 488 | const s = game.state; |
| 489 | expect(s.phase).toBe('handEnd'); |
| 490 | if (s.result!.drawGame) draws++; |
| 491 | else wins++; |
| 492 | |
| 493 | // the wall square's bookkeeping matches the array it draws from |
| 494 | expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144); |
| 495 | expect(s.drawnFront).toBeGreaterThanOrEqual(65); // the deal |
| 496 | |
| 497 | // tile conservation: every tile is accounted for exactly once |
| 498 | const all: Tile[] = [...s.wall]; |
| 499 | for (const p of s.players) all.push(...p.hand, ...p.flowers, ...p.discards, ...p.melds.flatMap((m) => m.tiles)); |
| 500 | expect(all.length).toBe(144); |
| 501 | for (let t = 0; t < NUM_BASIC; t++) expect(all.filter((x) => x === t).length).toBe(4); |
| 502 | |
| 503 | // hand sizes are legal |
| 504 | for (const seat of SEATS) { |
| 505 | const p = s.players[seat]; |
| 506 | const expected = 16 - p.melds.length * 3; |
| 507 | expect(p.hand.length).toBeGreaterThanOrEqual(expected); |
| 508 | expect(p.hand.length).toBeLessThanOrEqual(expected + 1); |
| 509 | } |
| 510 | |
| 511 | // chips are zero-sum |
| 512 | const total = s.players.reduce((a, p) => a + p.score, 0); |
| 513 | expect(total).toBe(400); |
| 514 | |
| 515 | game.nextHand(); |
| 516 | if (game.state.phase === 'gameEnd') break; |
| 517 | } |
| 518 | expect(wins + draws).toBeGreaterThan(0); |
| 519 | }); |
| 520 | }); |