anvilsign in

collin/mahjong

master / src / game / engine.test.ts
1import { describe, expect, it } from 'vitest';
2import { Game } from './engine';
3import { decompose, isWinningHand, waits } from './hu';
4import { scoreHand } from './tai';
5import { buildWall, NUM_BASIC, type Tile } from './tiles';
6import { DEFAULT_RULES, type Meld, type SeatId } from './types';
7
8const 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 */
15function 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
24describe('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
33describe('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
63describe('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 */
113function 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
153describe('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
191describe('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('names the tile, and whose it was, on the cues that are spoken aloud', () => {
229 const game = new Game();
230 const cues: { kind: string; tile?: number; seat?: SeatId }[] = [];
231 game.onSound((c) => cues.push(c));
232 game.startHand(7);
233 const s = game.state;
234 const victim = ((s.dealer + 2) % 4) as SeatId;
235 const tile = s.players[s.dealer].hand[0];
236 s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
237
238 game.discard(s.dealer, tile);
239 expect(cues).toContainEqual({ kind: 'discard', tile, seat: s.dealer });
240
241 game.respond(victim, { type: 'pung' });
242 game.resolveNow();
243 // The 碰 belongs to the seat that called it, not to the one that threw:
244 // that is what puts the shout at the right edge of the table, and hands it
245 // to the right phone at a party one.
246 expect(cues).toContainEqual({ kind: 'pung', tile, seat: victim });
247
248 // The table's own noises belong to nobody, and say so.
249 expect(cues.find((c) => c.kind === 'deal')?.seat).toBeUndefined();
250 });
251
252 it('takes back a mis-tapped discard', () => {
253 const game = new Game();
254 game.startHand(11);
255 const s = game.state;
256 const before = JSON.parse(JSON.stringify(s));
257 expect(game.undoable()).toBeNull();
258
259 game.discard(s.dealer, s.players[s.dealer].hand[0]);
260 expect(game.undoable()).not.toBeNull();
261 game.undo();
262
263 expect(game.state).toEqual(before);
264 expect(game.undoable()).toBeNull();
265 });
266
267 it('takes back a pass, handing the claim buttons back', () => {
268 const game = new Game();
269 game.startHand(7);
270 const s = game.state;
271 const victim = ((s.dealer + 2) % 4) as SeatId;
272 const tile = s.players[s.dealer].hand[0];
273 s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
274 game.discard(s.dealer, tile);
275
276 game.respond(victim, 'pass');
277 expect(game.state.responses[victim]).toBe('pass');
278 game.undo();
279 expect(game.state.responses[victim]).toBeUndefined();
280 expect(game.state.options[victim]).toBeTruthy();
281
282 // ...and the claim can then be made for real.
283 game.respond(victim, { type: 'pung' });
284 game.resolveNow();
285 expect(game.state.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true);
286 });
287
288 it('caps the undo stack and forgets the previous hand', () => {
289 const game = new Game();
290 game.startHand(3);
291 for (let i = 0; i < 30; i++) {
292 const s = game.state;
293 if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]);
294 else game.resolveNow();
295 }
296 let depth = 0;
297 while (game.undoable()) {
298 game.undo();
299 depth++;
300 }
301 expect(depth).toBe(20);
302
303 game.startHand(4);
304 expect(game.undoable()).toBeNull();
305 });
306
307 it('過水 locks a declined win out until the next draw', () => {
308 const game = new Game();
309 game.startHand(21);
310 const s = game.state;
311 // Put a seat one tile from home: four melds down, 123m + 白白 in hand,
312 // so it wins on 一萬 or 四萬.
313 const victim = ((s.dealer + 2) % 4) as SeatId;
314 setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
315 const tile = 3; // 四萬
316
317 game.state.players[s.dealer].hand = [tile, ...game.state.players[s.dealer].hand.slice(1)];
318 game.discard(s.dealer, tile);
319 expect(s.options[victim]?.some((o) => o.type === 'hu')).toBe(true);
320 game.respond(victim, 'pass');
321 game.resolveNow();
322 expect(game.sacredTiles(victim)).toContain(tile);
323
324 // Same tile again from the next seat round: no 胡 on offer this time.
325 const other = s.turn;
326 expect(other).not.toBe(victim);
327 game.state.players[other].hand = [tile, ...game.state.players[other].hand.slice(1)];
328 game.discard(other, tile);
329 expect(s.options[victim]?.some((o) => o.type === 'hu')).toBeFalsy();
330 });
331
332 it('過水 lifts once the locked seat draws again', () => {
333 const game = new Game();
334 game.startHand(21);
335 const s = game.state;
336 const victim = ((s.dealer + 2) % 4) as SeatId;
337 setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
338 s.sacred[victim] = [3];
339
340 // Walk the table round until the locked seat draws.
341 for (let i = 0; i < 40 && !(s.phase === 'turn' && s.turn === victim); i++) {
342 if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]);
343 else game.resolveNow();
344 }
345 expect(s.turn).toBe(victim);
346 expect(game.sacredTiles(victim)).toEqual([]);
347 });
348
349 it('過水 can be switched off, or lifted by a claim', () => {
350 for (const clearedByClaim of [false, true]) {
351 const game = new Game(undefined, { ...DEFAULT_RULES, sacredClearedByClaim: clearedByClaim });
352 game.startHand(21);
353 const s = game.state;
354 const victim = ((s.dealer + 1) % 4) as SeatId; // to the discarder's left, so it can chow
355 setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
356 s.sacred[victim] = [3];
357 game.state.players[s.dealer].hand = [3, ...game.state.players[s.dealer].hand.slice(1)];
358 game.discard(s.dealer, 3);
359 game.respond(victim, { type: 'chow', with: [1, 2] });
360 game.resolveNow();
361 expect(game.sacredTiles(victim)).toEqual(clearedByClaim ? [] : [3]);
362 }
363
364 const off = new Game(undefined, { ...DEFAULT_RULES, sacredDiscard: false });
365 off.startHand(21);
366 const seat = ((off.state.dealer + 2) % 4) as SeatId;
367 off.state.sacred[seat] = [3];
368 expect(off.sacredTiles(seat)).toEqual([]);
369 });
370
371 it('一炮多響 pays every seat that calls on the tile', () => {
372 const game = new Game(undefined, { ...DEFAULT_RULES, multipleWinners: true });
373 game.startHand(41);
374 const s = game.state;
375 const a = ((s.dealer + 1) % 4) as SeatId;
376 const b = ((s.dealer + 2) % 4) as SeatId;
377 setUpReadyHand(game, a, [1, 2, 33, 33]);
378 setUpReadyHand(game, b, [1, 2, 32, 32]);
379 s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)];
380
381 game.discard(s.dealer, 3);
382 game.respond(a, { type: 'hu' });
383 // The nearer seat does not get to close the door on the further one.
384 expect(s.phase).toBe('claims');
385 game.respond(b, { type: 'hu' });
386
387 const r = s.result!;
388 expect(r.winners.map((w) => w.seat).sort()).toEqual([a, b].sort());
389 expect(r.loser).toBe(s.dealer);
390 expect(r.deltas[a]).toBeGreaterThan(0);
391 expect(r.deltas[b]).toBeGreaterThan(0);
392 expect(r.deltas[s.dealer]).toBe(-(r.deltas[a] + r.deltas[b]));
393 expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0);
394 });
395
396 it('awards the discard to the nearest caller when 一炮多響 is off', () => {
397 const game = new Game();
398 game.startHand(41);
399 const s = game.state;
400 const a = ((s.dealer + 1) % 4) as SeatId;
401 const b = ((s.dealer + 2) % 4) as SeatId;
402 setUpReadyHand(game, a, [1, 2, 33, 33]);
403 setUpReadyHand(game, b, [1, 2, 32, 32]);
404 s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)];
405
406 game.discard(s.dealer, 3);
407 game.respond(b, { type: 'hu' });
408 game.respond(a, { type: 'hu' });
409 expect(s.result!.winners.map((w) => w.seat)).toEqual([a]);
410 });
411
412 it('包牌 — whoever fed the third dragon answers for the whole hand', () => {
413 for (const liability of [true, false]) {
414 const game = new Game(undefined, { ...DEFAULT_RULES, liability });
415 game.startHand(31);
416 const s = game.state;
417 const winner = ((s.dealer + 1) % 4) as SeatId;
418 const feeder = ((s.dealer + 2) % 4) as SeatId;
419 const other = ((s.dealer + 3) % 4) as SeatId;
420
421 const p = s.players[winner];
422 p.melds = [
423 { kind: 'pung', tiles: [31, 31, 31], from: other }, // 中
424 { kind: 'pung', tiles: [9, 9, 9], from: other },
425 { kind: 'pung', tiles: [32, 32, 32], from: other }, // 發
426 { kind: 'pung', tiles: [33, 33, 33], from: feeder }, // 白 — the third dragon
427 ];
428 p.hand = [1, 2, 5, 5, 3];
429 s.turn = winner;
430 s.phase = 'turn';
431 s.drawnTile = 3;
432
433 game.declareSelfDraw(winner);
434 const r = s.result!;
435 expect(r.winners[0].items.map((i) => i.name)).toContain('大三元');
436 expect(r.winners[0].liable).toBe(liability ? feeder : null);
437 if (liability) {
438 // Everyone else is out of it; the feeder covers all three shares.
439 expect(r.deltas[other]).toBe(0);
440 expect(r.deltas[s.dealer]).toBe(0);
441 expect(r.deltas[feeder]).toBe(-r.deltas[winner]);
442 } else {
443 expect(r.deltas[other]).toBeLessThan(0);
444 expect(r.deltas[feeder]).toBeLessThan(0);
445 }
446 expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0);
447 }
448 });
449
450 it('lets 包牌 go when the third dragon came from the winner’s own hand', () => {
451 const game = new Game();
452 game.startHand(31);
453 const s = game.state;
454 const winner = ((s.dealer + 1) % 4) as SeatId;
455 const other = ((s.dealer + 3) % 4) as SeatId;
456 const p = s.players[winner];
457 p.melds = [
458 { kind: 'pung', tiles: [31, 31, 31], from: other },
459 { kind: 'pung', tiles: [9, 9, 9], from: other },
460 { kind: 'pung', tiles: [32, 32, 32], from: other },
461 { kind: 'ankong', tiles: [33, 33, 33, 33] }, // 暗槓, nobody handed it over
462 ];
463 p.hand = [1, 2, 5, 5, 3];
464 s.turn = winner;
465 s.phase = 'turn';
466 s.drawnTile = 3;
467
468 game.declareSelfDraw(winner);
469 expect(s.result!.winners[0].items.map((i) => i.name)).toContain('大三元');
470 expect(s.result!.winners[0].liable).toBeNull();
471 });
472
473 it('keeps the chips zero-sum under every house rule', () => {
474 const variants = [
475 { multipleWinners: true },
476 { multipleWinners: true, sacredDiscard: false },
477 { liability: false, sacredClearedByClaim: true },
478 ];
479 for (const [v, patch] of variants.entries()) {
480 let seed = 900 + v;
481 const rng = () => {
482 seed = (seed * 1103515245 + 12345) & 0x7fffffff;
483 return seed / 0x7fffffff;
484 };
485 const game = new Game(undefined, { ...DEFAULT_RULES, ...patch });
486 for (let h = 0; h < 30; h++) {
487 game.startHand(h * 7919 + v);
488 playRandomHand(game, rng);
489 const s = game.state;
490 expect(s.phase).toBe('handEnd');
491 expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400);
492 expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144);
493 game.nextHand();
494 if (game.state.phase === 'gameEnd') break;
495 }
496 }
497 });
498
499 it('plays 200 random hands without corrupting state', () => {
500 let seed = 12345;
501 const rng = () => {
502 seed = (seed * 1103515245 + 12345) & 0x7fffffff;
503 return seed / 0x7fffffff;
504 };
505 const game = new Game();
506 let wins = 0;
507 let draws = 0;
508
509 for (let h = 0; h < 200; h++) {
510 game.startHand(h * 7919 + 3);
511 playRandomHand(game, rng);
512 const s = game.state;
513 expect(s.phase).toBe('handEnd');
514 if (s.result!.drawGame) draws++;
515 else wins++;
516
517 // the wall square's bookkeeping matches the array it draws from
518 expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144);
519 expect(s.drawnFront).toBeGreaterThanOrEqual(65); // the deal
520
521 // tile conservation: every tile is accounted for exactly once
522 const all: Tile[] = [...s.wall];
523 for (const p of s.players) all.push(...p.hand, ...p.flowers, ...p.discards, ...p.melds.flatMap((m) => m.tiles));
524 expect(all.length).toBe(144);
525 for (let t = 0; t < NUM_BASIC; t++) expect(all.filter((x) => x === t).length).toBe(4);
526
527 // hand sizes are legal
528 for (const seat of SEATS) {
529 const p = s.players[seat];
530 const expected = 16 - p.melds.length * 3;
531 expect(p.hand.length).toBeGreaterThanOrEqual(expected);
532 expect(p.hand.length).toBeLessThanOrEqual(expected + 1);
533 }
534
535 // chips are zero-sum
536 const total = s.players.reduce((a, p) => a + p.score, 0);
537 expect(total).toBe(400);
538
539 game.nextHand();
540 if (game.state.phase === 'gameEnd') break;
541 }
542 expect(wins + draws).toBeGreaterThan(0);
543 });
544});