anvilsign in

collin/mahjong

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('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});