UNPKG

gtp2ogs

Version:

Wrapper to allow Gnu Go Text Protocol speaking Go engines to connect to Online-Go.com and play games

1,031 lines (1,018 loc) 521 kB
#!/usr/bin/env node require("source-map-support/register"); /******/ (() => { // webpackBootstrap /******/ "use strict"; /******/ var __webpack_modules__ = ({ /***/ "./src/Bot.ts": /*!********************!*\ !*** ./src/Bot.ts ***! \********************/ /***/ (function(__unused_webpack_module, exports, __webpack_require__) { var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Bot = void 0; exports.gtpchar2num = gtpchar2num; exports.move2gtpvertex = move2gtpvertex; const trace_1 = __webpack_require__(/*! ./trace */ "./src/trace.ts"); const child_process_1 = __webpack_require__(/*! child_process */ "child_process"); const util_1 = __webpack_require__(/*! ./util */ "./src/util.ts"); const split2 = __webpack_require__(/*! split2 */ "split2"); const goban_engine_1 = __webpack_require__(/*! goban-engine */ "goban-engine"); const config_1 = __webpack_require__(/*! ./config */ "./src/config.ts"); const PvOutputParser_1 = __webpack_require__(/*! ./PvOutputParser */ "./src/PvOutputParser.ts"); const socket_1 = __webpack_require__(/*! ./socket */ "./src/socket.ts"); const eventemitter3_1 = __webpack_require__(/*! eventemitter3 */ "eventemitter3"); const gtpCommandEndRegex = new RegExp("(\\r?\\n){2}$"); const gtpCommandSplitRegex = new RegExp("(\\r?\\n){2}"); let last_bot_id = 0; /** Manages talking to a bot via the GTP interface */ class Bot extends eventemitter3_1.EventEmitter { constructor(bot_config, is_ending_bot = false) { super(); this.id = ++last_bot_id; this.game = null; this.available_commands = { /* These are required by the GTP spec */ protocol_version: true, name: true, version: true, known_command: true, list_commands: true, quit: true, boardsize: true, clear_board: true, komi: true, play: true, genmove: true, }; /** True if we are available for use by a Game. This flag is managed by the pool. */ this.available = true; this.persistent_state_loaded = false; this.persistent_moves_sent_count = 0; this.persistent_idle_timeout = null; /* These are affinity fields used by our pool to try and select good bots to use */ this.last_game_id = -1; this.last_width = -1; this.last_height = -1; this.bot_config = bot_config; this.persistent = bot_config.manager === "persistent" && !is_ending_bot; const cmd = bot_config.command.map((x) => x.replace(/^~[/]/g, process.env.HOME + "/")); this.log = trace_1.trace.log.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.info = trace_1.trace.info.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.trace = trace_1.trace.trace.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.verbose = trace_1.trace.debug.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.warn = trace_1.trace.warn.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.error = trace_1.trace.error.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}]`); this.commands_sent = 0; this.command_callbacks = []; this.command_error_callbacks = []; this.is_ending_bot = is_ending_bot; this.first_move = true; this.ignore = false; // Ignore output from bot ? // Set to true when the bot process has died and needs to be restarted before it can be used again. this.dead = false; // Set to true when there is a command failure or a bot failure and the game fail counter should be incremented. // After a few failures we stop retrying and resign the game. this.failed = false; this.pv_parser = new PvOutputParser_1.PvOutputParser(); this.verbose("Starting ", cmd.join(" ")); try { this.proc = (0, child_process_1.spawn)(cmd[0], cmd.slice(1)); this.proc.on("exit", () => { this.emit("terminated"); }); this.log = trace_1.trace.log.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}:${this.pid}]`); this.verbose = trace_1.trace.debug.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}:${this.pid}]`); this.warn = trace_1.trace.warn.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}:${this.pid}]`); this.error = trace_1.trace.error.bind(null, `[${this.is_ending_bot ? "resign bot" : "bot"} ${this.id}:${this.pid}]`); } catch (e) { this.log("Failed to start the bot: ", e); this.ignore = true; this.dead = true; this.failed = true; return; } this.proc.stderr.pipe(split2()).on("data", (data) => { if (this.ignore) { return; } const err_line = data.toString().trim(); if (err_line === "") { return; } this.log(`${err_line}`); if (this.pv_parser) { if (this.pv_parser.scanAndSendEngineAnalysis(this.game, err_line)) { return; } } if (this.bot_config.send_chats) { const chat_match = /(DISCUSSION|MALKOVICH|MAIN):(.*)/.exec(err_line); if (chat_match) { const channel = /MALKOVICH:/i.test(err_line) ? "malkovich" : "main"; this.emit("chat", chat_match[2], channel); } } }); let stdout_buffer = ""; this.proc.stdout.on("data", (data) => { if (this.ignore) { return; } stdout_buffer += data.toString(); if (!stdout_buffer || !gtpCommandEndRegex.test(stdout_buffer)) { //this.log("Partial result received, buffering until the output ends with a newline"); return; } this.trace("<<<", stdout_buffer.trim()); const lines = stdout_buffer.split(gtpCommandSplitRegex); stdout_buffer = ""; for (let i = 0; i < lines.length; ++i) { const line = lines[i]; if (line.trim() === "") { continue; } if (line[0] === "=") { while (lines[i].trim() !== "") { ++i; } const cb = this.command_callbacks.shift(); this.command_error_callbacks.shift(); // discard; if (cb) { cb(line.substring(1).trim()); } } else if (line.trim()[0] === "?") { this.log(line); while (lines[i].trim() !== "") { ++i; this.log(lines[i]); } this.failed = true; this.command_callbacks.shift(); // discard; const eb = this.command_error_callbacks.shift(); if (eb) { eb(line.substring(1).trim()); } } else { this.error("Unexpected output: ", line); this.failed = true; this.command_callbacks.shift(); // discard; const eb = this.command_error_callbacks.shift(); if (eb) { eb(line.trim()); } } } }); this.proc.on("exit", (code) => { const unexpected = !this.dead; if (unexpected) { if (code) { this.log("Bot exited", code); } else { this.log("Bot exited"); } } this.command_callbacks.shift(); // discard; this.dead = true; const eb = this.command_error_callbacks.shift(); if (unexpected && eb) { eb(code); } }); this.proc.stdin.on("error", (code) => { this.error("Bot stdin write error", code); this.command_callbacks.shift(); // discard; this.dead = true; this.failed = true; const eb = this.command_error_callbacks.shift(); if (eb) { eb(code); } }); this.ready = this.command("list_commands"); this.ready .then((commands) => { commands .split(/[\r\n]/) .filter((x) => x) .map((x) => (this.available_commands[x.replace(/^=/, "").trim()] = true)); this.emit("ready"); }) .catch((err) => { trace_1.trace.error(err); trace_1.trace.error("Failed to list bot commands, exiting."); process.exit(1); }); } get pid() { if (this.proc) { return this.proc.pid; } else { return -1; } } loadClock(state) { /* References: http://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html#sec:time-handling http://www.weddslist.com/kgs/how/kgsGtp.html GTP v2 only supports Canadian Byoyomi, no timer (see spec above), and absolute (period time zero). kgs-time_settings adds support for Japanese Byoyomi. The kgsGtp interface (http://www.weddslist.com/kgs/how/kgsGtp.html) converts byoyomi to absolute time for bots that don't support kgs-time_settings by using main_time plus periods * period_time. But then the bot would view that as the total time left for entire rest of game... Japanese Byoyomi with one period left could be viewed as a special case of Canadian Byoyomi where the number of stones is always = 1 */ if (!this.bot_config.enable_clock) { return; } // clock_drift compensates for difference between server and client time, and latency. const now = Date.now() - socket_1.socket.clock_drift; let black_offset = 0; let white_offset = 0; // offset indicates how long we've had since last move. Ogs only communicates how much // time the player had when last move was made. if (state.clock.current_player === state.clock.black_player_id) { black_offset = (now - state.clock.last_move) / 1000; } else { white_offset = (now - state.clock.last_move) / 1000; } if (state.time_control.system === "byoyomi") { let black_time = state.clock.black_time.thinking_time; let white_time = state.clock.white_time.thinking_time; let black_periods = state.clock.black_time.periods; let white_periods = state.clock.white_time.periods; let black_time_left = 0; let white_time_left = 0; if (this.kgs_time) { if (black_time > 0) { black_time_left = black_time - black_offset; } else { black_time_left = state.time_control.period_time - black_offset; } if (white_time > 0) { white_time_left = white_time - white_offset; } else { white_time_left = state.time_control.period_time - white_offset; } // If we're so slow that time left - offset is negative, we need to roll over to period time. while (black_time_left < 0 && black_periods > 1) { black_time_left += state.clock.black_time.period_time; if (black_time > 0) { black_time = 0; } else { black_periods--; } } while (white_time_left < 0 && white_periods > 1) { white_time_left += state.clock.white_time.period_time; if (white_time > 0) { white_time = 0; } else { white_periods--; } } (0, util_1.ignore_promise)(this.command(`kgs-time_settings byoyomi ${state.time_control.main_time} ${state.time_control.period_time} ${state.time_control.periods}`)); (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} ${black_time > 0 ? "0" : black_periods}`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} ${white_time > 0 ? "0" : white_periods}`)); } else { /* Gtp does not support Japanese Byoyomi. We fake it as Canadian Byoyomi. Let's pretend the final period is a Canadian Byoyomi of 1 stone. This lets the bot know it can use the full period per move, not try to fit the rest of the game into the time left. */ // add all periods to the main time. // If we're already in overtime, exclude the current period. if (black_time > 0) { black_time_left = black_time - black_offset + state.clock.black_time.period_time * black_periods; } else { black_time_left = state.time_control.period_time - black_offset + state.clock.black_time.period_time * (black_periods - 1); } if (white_time > 0) { white_time_left = white_time - white_offset + state.clock.black_time.period_time * black_periods; } else { white_time_left = state.time_control.period_time - white_offset + state.clock.black_time.period_time * (black_periods - 1); } (0, util_1.ignore_promise)(this.command(`time_settings ${state.time_control.main_time + (state.time_control.periods - 1) * state.time_control.period_time} ${state.time_control.period_time} 1`)); // If we're in the last period, tell the bot. Otherwise pretend we're in main time. if (black_time_left <= state.clock.black_time.period_time) { (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} 1`)); } else { (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(black_time_left - state.clock.black_time.period_time)} 0`)); } if (white_time_left <= state.clock.white_time.period_time) { (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} 1`)); } else { (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(white_time_left - state.clock.white_time.period_time)} 0`)); } } } else if (state.time_control.system === "canadian") { /* Canadian Byoyomi is the only time controls GTP v2 officially supports. */ let black_time_left = state.clock.black_time.thinking_time - black_offset; let white_time_left = state.clock.white_time.thinking_time - white_offset; let black_stones = 0; let white_stones = 0; if (black_time_left <= 0) { black_stones = state.clock.black_time.moves_left; black_time_left += state.clock.black_time.block_time; } if (white_time_left <= 0) { white_stones = state.clock.white_time.moves_left; white_time_left += state.clock.white_time.block_time; } if (this.kgs_time) { (0, util_1.ignore_promise)(this.command(`kgs-time_settings canadian ${state.time_control.main_time} ${state.time_control.period_time} ${state.time_control.stones_per_period}`)); } else { (0, util_1.ignore_promise)(this.command(`time_settings ${state.time_control.main_time} ${state.time_control.period_time} ${state.time_control.stones_per_period}`)); } (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} ${black_stones}`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} ${white_stones}`)); } else if (state.time_control.system === "fischer") { if (this.kata_fischer) { const black_time_left = state.clock.black_time.thinking_time - black_offset; const white_time_left = state.clock.white_time.thinking_time - white_offset; (0, util_1.ignore_promise)(this.command(`kata-time_settings fischer-capped ${state.time_control.initial_time} ${state.time_control.time_increment} ${state.time_control.max_time} -1`)); (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} 0`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} 0`)); } else { /* Not supported by kgs-time_settings and I assume most bots. A better way than absolute is to handle this with a fake Canadian Byoyomi. This should let the bot know a good approximation of how to handle the time remaining. */ let black_time_left = state.clock.black_time.thinking_time - black_offset - state.time_control.time_increment; let white_time_left = state.clock.white_time.thinking_time - white_offset - state.time_control.time_increment; let black_periods = 0; let white_periods = 0; if (this.kgs_time) { (0, util_1.ignore_promise)(this.command(`kgs-time_settings canadian ${state.time_control.initial_time - state.time_control.time_increment} ${state.time_control.time_increment} 1`)); } else { (0, util_1.ignore_promise)(this.command(`time_settings ${state.time_control.initial_time - state.time_control.time_increment} ${state.time_control.time_increment} 1`)); } if (black_time_left <= 0) { black_periods = 1; black_time_left += state.time_control.time_increment; } if (white_time_left <= 0) { white_periods = 1; white_time_left += state.time_control.time_increment; } /* Always tell the bot we are in main time ('0') so it doesn't try to think all of time_left per move. But subtract the increment time above to avoid timeouts. */ (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} ${black_periods}`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} ${white_periods}`)); } } else if (state.time_control.system === "simple") { /* Simple could also be viewed as a Canadian Byoyomi that starts immediately with # of stones = 1 */ // for some reason ogs sends a timestamp (equal to state.clock.last_move) instead of our time. // Luckily we can use state.time_control.per_move since simple time is always in overtime. const black_time_left = state.time_control.per_move - black_offset; const white_time_left = state.time_control.per_move - white_offset; (0, util_1.ignore_promise)(this.command(`time_settings 0 ${state.time_control.per_move} 1`)); (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} 1`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} 1`)); } else if (state.time_control.system === "absolute") { const black_time_left = state.clock.black_time.thinking_time - black_offset; const white_time_left = state.clock.white_time.thinking_time - white_offset; (0, util_1.ignore_promise)(this.command(`time_settings ${state.time_control.total_time} 0 0`)); (0, util_1.ignore_promise)(this.command(`time_left black ${Math.floor(Math.max(black_time_left, 0))} 0`)); (0, util_1.ignore_promise)(this.command(`time_left white ${Math.floor(Math.max(white_time_left, 0))} 0`)); } /* OGS doesn't actually send 'none' time control type else if (state.time_control.system === 'none') { if (this.kgstime) { this.command("kgs-time_settings none"); } else { // GTP v2 says byoyomi time > 0 and stones = 0 means no time limits // this.command("time_settings 0 1 0"); } } */ } loadState(state) { return __awaiter(this, void 0, void 0, function* () { if (this.dead) { this.failed = true; this.error("Attempted to load state to a dead bot"); throw new Error("Attempting to load dead bot"); } this.kgs_time = !!this.available_commands["kgs-time_settings"]; this.kata_time = !!this.available_commands["kata-list_time_settings"]; if (this.kata_time) { const kataTimeSettings = yield this.command("kata-list_time_settings"); this.kata_fischer = kataTimeSettings.includes("fischer-capped"); } else { this.kata_fischer = false; } const do_initial_load = !this.persistent || !this.persistent_state_loaded; let have_initial_state = false; if (do_initial_load) { // Update our affinity fields this.last_game_id = state.game_id; this.last_width = state.width; this.last_height = state.height; if (state.width === state.height) { yield this.command(`boardsize ${state.width}`); } else { yield this.command(`boardsize ${state.width} ${state.height}`); } yield this.command("clear_board"); yield this.command(`komi ${state.komi}`); if (state.initial_state) { const black = (0, goban_engine_1.decodeMoves)(state.initial_state.black, state.width, state.height); const white = (0, goban_engine_1.decodeMoves)(state.initial_state.white, state.width, state.height); have_initial_state = !!black.length || !!white.length; for (let i = 0; i < black.length; ++i) { yield this.command(`play black ${move2gtpvertex(black[i], state.width, state.height)}`); } for (let i = 0; i < white.length; ++i) { yield this.command(`play white ${move2gtpvertex(white[i], state.width, state.height)}`); } } } // Replay moves made let color = state.initial_player; const doing_handicap = !have_initial_state && state.free_handicap_placement && state.handicap > 1; const handicap_moves = []; const moves = (0, goban_engine_1.decodeMoves)(state.moves, state.width, state.height); for (let i = 0; i < moves.length; ++i) { const move = moves[i]; // Use set_free_handicap for handicap stones, play otherwise. if (doing_handicap && handicap_moves.length < state.handicap) { handicap_moves.push(move); if (handicap_moves.length === state.handicap) { if (do_initial_load) { (0, util_1.ignore_promise)(this.sendHandicapMoves(handicap_moves, state.width, state.height)); if (this.persistent) { this.persistent_moves_sent_count += handicap_moves.length; } } } else { continue; } // don't switch color. } else { if (do_initial_load || (this.persistent && this.persistent_moves_sent_count <= i)) { if (this.persistent) { ++this.persistent_moves_sent_count; } yield this.command(`play ${color} ${move2gtpvertex(move, state.width, state.height)}`); } } color = color === "black" ? "white" : "black"; } if (this.persistent) { this.persistent_state_loaded = true; } if (config_1.config.showboard) { return yield this.command("showboard"); } return ""; }); } command(str, _final_command) { const arr = str.trim().split(/\s+/); if (arr.length > 0) { const cmd = arr[0]; if (!this.available_commands[cmd]) { this.warn(`Bot does not support command ${cmd}`); //return Promise.reject(`Bot does not support command ${cmd}`); return Promise.resolve("="); } } return new Promise((resolve, reject) => { if (this.dead) { this.error("Attempting to send a command to dead bot:", str); this.failed = true; reject(`Attempting to send a command to dead bot: ${str}`); return; } this.command_callbacks.push(resolve); this.command_error_callbacks.push(reject); this.trace(">>>", str); try { this.proc.stdin.write(`${str}\r\n`); } catch (e) { // I think this does not normally happen, the exception will usually be raised in the async write handler // and delivered through an 'error' event. // this.error("Failed to send command: ", str); this.error(e); this.dead = true; this.failed = true; // Already calling the callback! this.command_error_callbacks.shift(); reject(e); return; } }); } // For commands like genmove, place_free_handicap ... : // Send @cmd to engine and call @cb with returned moves. getMoves(cmd, state) { return __awaiter(this, void 0, void 0, function* () { this.loadClock(state); // Must be after loadClock() since loadClock() checks this.first_move! this.first_move = false; const line = yield this.command(cmd, true /* final command */); const parts = line.toLowerCase().split(/ +/); const moves = []; for (let i = 0; i < parts.length; i++) { const move = parts[i]; let resign = move === "resign"; const pass = move === "pass"; let x = -1; let y = -1; if (!resign && !pass) { if (move && move[0]) { x = gtpchar2num(move[0]); y = state.height - parseInt(move.substring(1)); } else { this.log(`${cmd} failed, resigning`); resign = true; } } moves.push({ x, y, text: move, resign, pass }); if (this.persistent) { ++this.persistent_moves_sent_count; } } if (config_1.config.showboard) { yield this.command("showboard"); } return moves; }); } kill() { this.log("Stopping bot"); this.ignore = true; // Prevent race conditions / inconsistencies. Could be in the middle of genmove ... // "quit" needs to be sent before we toggle this.dead since command() checks the status of this.dead (0, util_1.ignore_promise)(this.command("quit")); this.dead = true; if (this.proc) { setTimeout(() => { if (this.proc.exitCode !== null) { this.log("Bot exited with code", this.proc.exitCode); return; } this.warn(`Bot didn't exit after ${this.bot_config.quit_grace_period}ms with "quit" message, killing forcefully`); this.proc.kill("SIGTERM"); setTimeout(() => { if (this.proc.exitCode !== null) { this.log("Bot exited with code", this.proc.exitCode); return; } this.proc.kill("SIGKILL"); }, this.bot_config.quit_grace_period); }, this.bot_config.quit_grace_period); } } sendMove(move, width, height, color) { return __awaiter(this, void 0, void 0, function* () { this.verbose("Calling sendMove with", move2gtpvertex(move, width, height)); yield this.command(`play ${color} ${move2gtpvertex(move, width, height)}`); }); } sendHandicapMoves(moves, width, height) { return __awaiter(this, void 0, void 0, function* () { let cmd = "set_free_handicap"; for (let i = 0; i < moves.length; i++) { cmd += ` ${move2gtpvertex(moves[i], width, height)}`; } yield this.command(cmd); }); } // Called on game over, in case you need something special. /* gameOver() { // } */ setGame(game) { this.game = game; } } exports.Bot = Bot; function gtpchar2num(ch) { if (ch === "." || !ch) { return -1; } return "abcdefghjklmnopqrstuvwxyz".indexOf(ch.toLowerCase()); } function move2gtpvertex(move, _width, height) { if (move.x < 0) { return "pass"; } return num2gtpchar(move["x"]) + (height - move["y"]); } function num2gtpchar(num) { if (num === -1) { return "."; } return "abcdefghjklmnopqrstuvwxyz"[num]; } /***/ }), /***/ "./src/Game.ts": /*!*********************!*\ !*** ./src/Game.ts ***! \*********************/ /***/ (function(__unused_webpack_module, exports, __webpack_require__) { var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Game = void 0; exports.handleChatLine = handleChatLine; const goban_engine_1 = __webpack_require__(/*! goban-engine */ "goban-engine"); const util_1 = __webpack_require__(/*! ./util */ "./src/util.ts"); const trace_1 = __webpack_require__(/*! ./trace */ "./src/trace.ts"); const socket_1 = __webpack_require__(/*! ./socket */ "./src/socket.ts"); const config_1 = __webpack_require__(/*! ./config */ "./src/config.ts"); const eventemitter3_1 = __webpack_require__(/*! eventemitter3 */ "eventemitter3"); const pools_1 = __webpack_require__(/*! ./pools */ "./src/pools.ts"); /** This manages a single game */ class Game extends eventemitter3_1.EventEmitter { constructor(game_id) { super(); this.using_opening_bot = false; this.endbot_resign_count = 0; if (!game_id) { throw new Error(`Invalid game id: ${game_id}`); } this.game_id = game_id; this.log = trace_1.trace.log.bind(null, `[game ${game_id}]`); this.trace = trace_1.trace.trace.bind(null, `[game ${game_id}]`); this.verbose = trace_1.trace.debug.bind(null, `[game ${game_id}]`); this.warn = trace_1.trace.warn.bind(null, `[game ${game_id}]`); this.error = trace_1.trace.error.bind(null, `[game ${game_id}]`); this.startup_timestamp = Date.now() / 1000; this.state = null; this.opponent_evenodd = null; this.greeted = false; this.bot = undefined; this.ending_bot = undefined; this.bot_failures = 0; this.my_color = null; this.processing = false; this.handicap_moves = []; // Handicap stones waiting to be sent when bot is playing black. this.disconnect_timeout = null; this.log("Connecting to game."); // TODO: Command line options to allow undo? // const on_undo_requested = (undo_data) => { this.log("Undo requested", JSON.stringify(undo_data, null, 4)); }; socket_1.socket.on(`game/${game_id}/undo_requested`, on_undo_requested); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/undo_requested`, on_undo_requested); }); const on_gamedata = (gamedata) => { if (!socket_1.socket.connected) { return; } // Server has an issue that gamedata.clock.now will exist inconsistently. This will cause // false positives for gamedata changes. We never use the field, so just remove it. delete gamedata.clock.now; // auto_score also sometimes inconsistent. We don't use it, so ignore it to avoid pointless // restart. /* TODO: check and see if auto_score is still sent, I don't think it is (anoek 2023-03-24) */ delete gamedata.auto_score; // Only call game over handler if game really just finished. // For some reason we get connected to already finished games once in a while ... if (gamedata.phase === "finished") { if (this.state && gamedata.phase !== this.state.phase) { this.state = gamedata; (0, util_1.ignore_promise)(this.gameOver()); } return; // ignore -- it's either handled by gameOver or we already handled it before. } const gamedataChanged = this.state ? JSON.stringify(this.state) !== JSON.stringify(gamedata) : false; // If the gamedata is identical to current state, it's a duplicate. Ignore it and do nothing, unless // bot is not running. // if (this.state && !gamedataChanged && this.bot && !this.bot.dead) { this.log("Ignoring gamedata that matches current state"); return; } // If server has issues it might send us a new gamedata packet and not a move event. We could try to // check if we're missing a move and send it to bot out of gamedata. For now as a safe fallback just // restart the bot by killing it here if another gamedata comes in. There normally should only be one // before we process any moves, and makeMove() is where a new Bot is created. // if (this.bot && gamedataChanged) { this.log("Killing bot because of gamedata change after bot was started"); this.verbose("Previously seen gamedata:", this.state); this.verbose("New gamedata:", gamedata); (0, util_1.ignore_promise)(this.releaseBots()); if (this.processing) { this.processing = false; --Game.moves_processing; } } //this.log("Gamedata:", JSON.stringify(gamedata, null, 4)); this.state = gamedata; this.my_color = config_1.config.bot_id === this.state.players.black.id ? "black" : "white"; this.log(`gamedata ${this.header()}`); // First handicap is just lower komi, more handicaps may change who is even or odd move #s. // if (this.state.free_handicap_placement && this.state.handicap > 1) { //In Chinese, black makes multiple free moves. // this.opponent_evenodd = this.my_color === "black" ? 0 : 1; this.opponent_evenodd = (this.opponent_evenodd + this.state.handicap - 1) % 2; } else if (this.state.handicap > 1) { // In Japanese, white makes the first move. // this.opponent_evenodd = this.my_color === "black" ? 1 : 0; } else { // If the game has a handicap, it can't be a fork and the above code works fine. // If the game has no handicap, it's either a normal game or a fork. Forks may have reversed turn ordering. // if (this.state.clock.current_player === config_1.config.bot_id) { this.opponent_evenodd = this.state.moves.length % 2; } else { this.opponent_evenodd = (this.state.moves.length + 1) % 2; } } // active_game isn't handling this for us any more. If it is our move, call makeMove. // if (this.state.phase === "play" && this.state.clock.current_player === config_1.config.bot_id) { if (!this.bot || !this.processing) { (0, util_1.ignore_promise)(this.makeMove(this.state.moves.length)); } } this.checkForPause(); }; socket_1.socket.on(`game/${game_id}/gamedata`, on_gamedata); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/gamedata`, on_gamedata); }); const on_clock = (clock) => { if (!socket_1.socket.connected) { return; } // Server has an issue that gamedata.clock.now will exist inconsistently. This will cause // false positives for gamedata changes. We never use the field, so just remove it. delete clock.now; if (this.state) { this.state.clock = clock; } else { this.error(`Received clock for ${this.game_id} but no state exists`); } this.checkForPause(); }; socket_1.socket.on(`game/${game_id}/clock`, on_clock); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/clock`, on_clock); }); const on_phase = (phase) => { if (!socket_1.socket.connected) { return; } this.log("phase", phase); //this.log("Move: ", move); if (this.state) { this.state.phase = phase; } else { trace_1.trace.error(`Received phase for ${this.game_id} but no state exists`); } if (phase === "play") { this.scheduleRetry(); } }; socket_1.socket.on(`game/${game_id}/phase`, on_phase); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/phase`, on_phase); }); const on_move = (move) => { if (!socket_1.socket.connected) { return; } this.trace(`game/${game_id}/move:`, move); if (!this.state) { trace_1.trace.error(`Received move for ${this.game_id} but no state exists`); // Try to connect again, to get the server to send the gamedata over. socket_1.socket.send("game/connect", { game_id: game_id, chat: config_1.config.log_game_chat, }); return; } if (move.move_number !== this.state.moves.length + 1) { trace_1.trace.error(`Received move for ${this.game_id} but move_number is invalid. ${move.move_number} !== ${this.state.moves.length + 1}`); return; } try { this.state.moves.push(move.move); // Log opponent moves const m = (0, goban_engine_1.decodeMoves)(move.move, this.state.width, this.state.height)[0]; if ((this.my_color === "white" && this.state.handicap >= this.state.moves.length) || move.move_number % 2 === this.opponent_evenodd) { this.log(`Opponent played ${(0, util_1.move2gtpvertex)(m, this.state.width, this.state.height)}`); } } catch (e) { trace_1.trace.error(e); } // If we're in free placement handicap phase of the game, make extra moves or wait it out, as appropriate. // // If handicap === 1, no extra stones are played. // If we are black, we played after initial gamedata and so handicap is not < length. // If we are white, this.state.moves.length will be 1 and handicap is not < length. // // If handicap >= 1, we don't check for opponent_evenodd to move on our turns until handicaps are finished. // if (this.state.free_handicap_placement && this.state.handicap > this.state.moves.length) { if (this.my_color === "black") { // If we are black, we make extra moves. // (0, util_1.ignore_promise)(this.makeMove(this.state.moves.length)); } else { // If we are white, we wait for opponent to make extra moves. if (this.bot) { (0, util_1.ignore_promise)(this.bot.sendMove((0, goban_engine_1.decodeMoves)(move.move, this.state.width, this.state.height)[0], this.state.width, this.state.height, "black")); } if (this.ending_bot) { (0, util_1.ignore_promise)(this.ending_bot.sendMove((0, goban_engine_1.decodeMoves)(move.move, this.state.width, this.state.height)[0], this.state.width, this.state.height, "black")); } this.verbose("Waiting for opponent to finish", this.state.handicap - this.state.moves.length, "more handicap moves"); if (this.state.moves.length === 1) { // remind once, avoid spamming the reminder this.sendChat("Waiting for opponent to place all handicap stones"); // reminding human player in in-game chat } } } else { const opponent_color = this.my_color === "black" ? "white" : "black"; if (move.move_number % 2 === this.opponent_evenodd) { // We just got a move from the opponent, so we can move immediately. // if (this.bot) { (0, util_1.ignore_promise)(this.bot.sendMove((0, goban_engine_1.decodeMoves)(move.move, this.state.width, this.state.height)[0], this.state.width, this.state.height, opponent_color)); } if (this.ending_bot) { (0, util_1.ignore_promise)(this.ending_bot.sendMove((0, goban_engine_1.decodeMoves)(move.move, this.state.width, this.state.height)[0], this.state.width, this.state.height, opponent_color)); } (0, util_1.ignore_promise)(this.makeMove(this.state.moves.length)); //this.makeMove(this.state.moves.length); } else { //this.verbose("Ignoring our own move", move.move_number); } } }; socket_1.socket.on(`game/${game_id}/move`, on_move); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/move`, on_move); }); if (config_1.config.log_game_chat) { socket_1.socket.send("chat/join", { channel: `game-${game_id}`, }); const on_chat = (d) => { // Since there is no explicit tracking of which chats are // "read", we assume anything from before we connected to the // game has already been dealt with. handleChatLine(game_id, d.line, this.startup_timestamp); }; socket_1.socket.on(`game/${game_id}/chat`, on_chat); this.on("disconnecting", () => { socket_1.socket.off(`game/${game_id}/chat`, on_chat); }); } socket_1.socket.send("game/connect", { game_id: game_id, chat: config_1.config.log_game_chat, }); /* this.connect_timeout = setTimeout(() => { if (!this.state) { this.log("No gamedata after 1s, requesting again"); this.scheduleRetry(); } }, 1000); */ this.connect_timeout = setTimeout(() => { if (!this.state) { this.warn("No gamedata received after 5s, still waiting"); } }, 5000); } // Release the bot to the pool. Because we are interested in the STDERR output // coming from a bot shortly after it's made a move, we don't release it right // away when this is called. releaseBots(final_release = false) { const promises = []; this.verbose("Releasing bot(s)"); this.verbose("Bot count available: " + pools_1.bot_pools.main.countAvailable()); if (this.bot) { const bot = this.bot; this.bot = undefined; const using_opening_bot = this.using_opening_bot; promises.push(new Promise((resolve, _reject) => { console.log("Will release bots in ", bot.bot_config.release_delay, "ms"); setTimeout(() => { try { console.log("Releasing bot"); bot.off("chat"); if (using_opening_bot) { console.log("Releasing opening bot"); pools_1.bot_pools.opening.release(bot); } else { console.log("Releasing main bot"); pools_1.bot_pools.main.release(bot); console.log("released, Bot count available: " + pools_1.bot_pools.main.countAvailable()); } resolve(); } catch (e) { console.error("Error releasing bot", e); } }, bot.bot_config.release_delay); })); } if (this.ending_bot) { const ending_bot = this.ending_bot; this.ending_bot = undefined; promises.push(new Promise((resolve, _reject) => { setTimeout(() => { ending_bot.off("chat"); pools_1.bot_pools.ending.release(ending_bo