anvilsign in

collin/mahjong

1import { botClaim, botDiscard, botKong, botView } from './bot';
2import type { Game } from './engine';
3import type { SeatId } from './types';
4
5const SEATS: SeatId[] = [0, 1, 2, 3];
6
7/**
8 * When the computer players move.
9 *
10 * The engine has no idea any of this exists: this watches the same state
11 * everyone else does and, when the position belongs to a bot, calls exactly
12 * the method the corresponding button would have called. That is the whole
13 * trick — a bot's move goes through `discard` / `respond` / `declare…` like
14 * anyone else's, so scoring, sound, saving and undo all keep working.
15 *
16 * It never acts for a person and never closes a person's claim window: if the
17 * table is waiting on you, it waits. What the bots actually decide is
18 * `bot.ts`; what they are allowed to see is `danger.ts`.
19 */
20
21/**
22 * How long the computer appears to think. Not a difficulty setting — it is
23 * there so a person can see what happened. A discard needs long enough to read
24 * off the table; a 碰 or a 過 comes back quicker, because three seats answer
25 * one after another and the wait is cumulative.
26 */
27const THINK_DISCARD = 750;
28const THINK_CLAIM = 380;
29/** A little unevenness, so the table doesn't tick like a metronome. */
30const JITTER = 250;
31
32/** Whose move it is, if it is a bot's, and how long to sit on it. */
33export function pendingBot(game: Game): { seat: SeatId; delay: number } | null {
34 const s = game.state;
35 if (s.phase === 'turn' && game.isBot(s.turn)) return { seat: s.turn, delay: THINK_DISCARD };
36 if (s.phase === 'claims' || s.phase === 'robkong') {
37 const seat = SEATS.find(
38 (q) => game.isBot(q) && s.options[q] !== undefined && s.responses[q] === undefined,
39 );
40 if (seat !== undefined) return { seat, delay: THINK_CLAIM };
41 }
42 return null;
43}
44
45/**
46 * Play one computer move, if one is owed. Returns whether it did anything —
47 * false means the table is waiting on a person, or on nothing at all.
48 */
49export function stepBots(game: Game): boolean {
50 const s = game.state;
51 const pending = pendingBot(game);
52 if (!pending) return false;
53 const seat = pending.seat;
54
55 if (s.phase === 'claims' || s.phase === 'robkong') {
56 const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile;
57 if (tile === null || tile === undefined) return false;
58 game.respond(seat, botClaim(s, seat, tile, s.options[seat]!));
59 return true;
60 }
61
62 if (game.canSelfDrawWin(seat)) {
63 game.declareSelfDraw(seat);
64 return true;
65 }
66 // Only worth asking while there is a wall to draw the replacement from — the
67 // engine turns a kong down flat once it is gone, and a refused move would
68 // leave the table sitting here forever.
69 if (game.wallLeft > 0) {
70 const kong = botKong(s, seat, game.concealedKongs(seat), game.addedKongs(seat));
71 if (kong?.kind === 'ankong') {
72 game.declareConcealedKong(seat, kong.tile);
73 return true;
74 }
75 if (kong?.kind === 'addkong') {
76 game.declareAddedKong(seat, kong.tile);
77 return true;
78 }
79 }
80
81 const p = s.players[seat];
82 const view = botView(s, seat);
83 game.discard(seat, botDiscard(p.hand, p.melds.length, view.unseen, view.danger));
84 return true;
85}
86
87export class AutoPlay {
88 private timer: ReturnType<typeof setTimeout> | null = null;
89 private game: Game;
90
91 constructor(game: Game) {
92 this.game = game;
93 }
94
95 /** Start watching. Returns the disposer. */
96 start(): () => void {
97 const off = this.game.subscribe(this.schedule);
98 this.schedule();
99 return () => {
100 off();
101 this.cancel();
102 };
103 }
104
105 private cancel() {
106 if (this.timer !== null) clearTimeout(this.timer);
107 this.timer = null;
108 }
109
110 private schedule = () => {
111 this.cancel();
112 const pending = pendingBot(this.game);
113 if (!pending) return;
114 const wait = pending.delay + Math.floor(Math.random() * JITTER);
115 // The state can move under us — a person taking an undo, say — so the move
116 // is worked out again at the moment it is played, never here.
117 const at = this.game.getSnapshot();
118 this.timer = setTimeout(() => {
119 this.timer = null;
120 if (this.game.getSnapshot() !== at) return;
121 stepBots(this.game);
122 }, wait);
123 };
124}