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/** How often to look again while a thrown tile is still in the air. */
32const SETTLE_POLL = 70;
33/**
34 * The longest the table will hold for a tile to land before going on anyway.
35 * Waiting on the animation is a courtesy; a game that could not continue because
36 * something in the middle would not settle would be a game that had stopped.
37 */
38const SETTLE_LIMIT = 2500;
39
40/** Whose move it is, if it is a bot's, and how long to sit on it. */
41export function pendingBot(game: Game): { seat: SeatId; delay: number } | null {
42 const s = game.state;
43 if (s.phase === 'turn' && game.isBot(s.turn)) return { seat: s.turn, delay: THINK_DISCARD };
44 if (s.phase === 'claims' || s.phase === 'robkong') {
45 const seat = SEATS.find(
46 (q) => game.isBot(q) && s.options[q] !== undefined && s.responses[q] === undefined,
47 );
48 if (seat !== undefined) return { seat, delay: THINK_CLAIM };
49 }
50 return null;
51}
52
53/**
54 * Play one computer move, if one is owed. Returns whether it did anything —
55 * false means the table is waiting on a person, or on nothing at all.
56 */
57export function stepBots(game: Game): boolean {
58 const s = game.state;
59 const pending = pendingBot(game);
60 if (!pending) return false;
61 const seat = pending.seat;
62
63 if (s.phase === 'claims' || s.phase === 'robkong') {
64 const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile;
65 if (tile === null || tile === undefined) return false;
66 game.respond(seat, botClaim(s, seat, tile, s.options[seat]!));
67 return true;
68 }
69
70 if (game.canSelfDrawWin(seat)) {
71 game.declareSelfDraw(seat);
72 return true;
73 }
74 // Only worth asking while there is a wall to draw the replacement from — the
75 // engine turns a kong down flat once it is gone, and a refused move would
76 // leave the table sitting here forever.
77 if (game.wallLeft > 0) {
78 const kong = botKong(s, seat, game.concealedKongs(seat), game.addedKongs(seat));
79 if (kong?.kind === 'ankong') {
80 game.declareConcealedKong(seat, kong.tile);
81 return true;
82 }
83 if (kong?.kind === 'addkong') {
84 game.declareAddedKong(seat, kong.tile);
85 return true;
86 }
87 }
88
89 const p = s.players[seat];
90 const view = botView(s, seat);
91 game.discard(seat, botDiscard(p.hand, p.melds.length, view.unseen, view.danger));
92 return true;
93}
94
95export class AutoPlay {
96 private timer: ReturnType<typeof setTimeout> | null = null;
97 private game: Game;
98 private busy: () => boolean;
99
100 /**
101 * `busy` is asked whether the table is mid-animation — a discard still in the
102 * air on its way to the middle. Nobody at a real table reaches across for a
103 * tile that has not landed, and a 碰 called over a tile still turning over in
104 * flight looks like the tile was snatched out of the air. The engine has no
105 * idea any of this exists; this is the same watching-from-outside trick the
106 * rest of the file plays.
107 */
108 constructor(game: Game, busy: () => boolean = () => false) {
109 this.game = game;
110 this.busy = busy;
111 }
112
113 /** Start watching. Returns the disposer. */
114 start(): () => void {
115 const off = this.game.subscribe(this.schedule);
116 this.schedule();
117 return () => {
118 off();
119 this.cancel();
120 };
121 }
122
123 private cancel() {
124 if (this.timer !== null) clearTimeout(this.timer);
125 this.timer = null;
126 }
127
128 private schedule = () => {
129 this.cancel();
130 const pending = pendingBot(this.game);
131 if (!pending) return;
132 const wait = pending.delay + Math.floor(Math.random() * JITTER);
133 // The state can move under us — a person taking an undo, say — so the move
134 // is worked out again at the moment it is played, never here.
135 const at = this.game.getSnapshot();
136 let waitedFrom = 0;
137
138 const play = () => {
139 this.timer = null;
140 if (this.game.getSnapshot() !== at) return;
141 if (this.busy()) {
142 const now = Date.now();
143 if (!waitedFrom) waitedFrom = now;
144 if (now - waitedFrom < SETTLE_LIMIT) {
145 this.timer = setTimeout(play, SETTLE_POLL);
146 return;
147 }
148 }
149 stepBots(this.game);
150 };
151
152 this.timer = setTimeout(play, wait);
153 };
154}