anvilsign in

collin/mahjong

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