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