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
JavaScript
#!/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