` and text string
for (let item of this.ordered_nodes_cache) {
if (item === this.root) {
continue;
}
// As a crude hack, the item can be a bracket string.
// Deal with that first...
if (typeof item === "string") {
pseudoelements.push({
opener: "",
text: item,
closer: ""
});
continue;
}
// So item is a real node...
let node = item;
let classes = [];
if (node === this.node) {
if (node.is_main_line()) {
classes.push("movelist_highlight_blue");
} else {
classes.push("movelist_highlight_yellow");
}
}
if (node.current_line) {
classes.push("white"); // Otherwise, inherits gray colour from movelist CSS
}
pseudoelements.push({
opener: ``,
text: node.token(),
closer: ``
});
}
let all_spans = [];
for (let n = 0; n < pseudoelements.length; n++) {
let p = pseudoelements[n];
let nextp = pseudoelements[n + 1]; // Possibly undefined
if (!nextp || (p.text !== "(" && nextp.text !== ")")) {
p.text += " ";
}
all_spans.push(`${p.opener}${p.text}${p.closer}`);
}
movelist.innerHTML = all_spans.join("");
// Undo the damage to our tree from the start...
foo = line_end;
while(foo) {
delete foo.current_line;
foo = foo.parent;
}
delete main_line_end.main_line_end;
// And finally...
this.fix_scrollbar_position();
},
// Helpers...
get_movelist_highlight: function() {
let elements = document.getElementsByClassName("movelist_highlight_blue");
if (elements && elements.length > 0) {
return elements[0];
}
elements = document.getElementsByClassName("movelist_highlight_yellow");
if (elements && elements.length > 0) {
return elements[0];
}
return null;
},
fix_scrollbar_position: function() {
let highlight = this.get_movelist_highlight();
if (highlight) {
let top = highlight.offsetTop - movelist.offsetTop;
if (top < movelist.scrollTop) {
movelist.scrollTop = top;
}
let bottom = top + highlight.offsetHeight;
if (bottom > movelist.scrollTop + movelist.offsetHeight) {
movelist.scrollTop = bottom - movelist.offsetHeight;
}
} else {
movelist.scrollTop = 0;
}
},
};
================================================
FILE: files/src/renderer/75_looker.js
================================================
"use strict";
// Rate limit strategy - thanks to Sopel:
//
// .running holds the item in-flight.
// .pending holds a single item to send after.
//
// Note: Don't store the retrieved info in the node.table, because the logic
// there is already a bit convoluted with __touched, __ghost and whatnot (sadly).
//
// Note: format of entries in the DB is {type: "foo", moves: {}}
// where moves is a map of string --> object
function NewLooker() {
let looker = Object.create(null);
looker.running = null;
looker.pending = null;
looker.all_dbs = Object.create(null);
looker.bans = Object.create(null); // db --> time of last rate-limit
Object.assign(looker, looker_props);
return looker;
}
let looker_props = {
clear_queue: function() {
this.running = null;
this.pending = null;
},
add_to_queue: function(board) {
if (!config.looker_api || !board.normalchess) {
return;
}
if (!config.look_past_25 && board.fullmove > 25) {
return;
}
// Is there a reason the test for whether we've already looked up this
// position isn't done here, but is done later at query_api()? I forget.
let query = { // Since queries are objects, different queries can always be told apart.
board: board,
db_name: config.looker_api
};
if (!this.running) {
this.send_query(query);
} else {
this.pending = query;
}
},
send_query: function(query) {
this.running = query;
// It is ESSENTIAL that every call to send_query() eventually generates a call to query_complete()
// so that the item gets removed from the queue. While we don't really need to use promises, doing
// it as follows lets me just have a single place where query_complete() is called. I guess.
this.query_api(query).catch(error => {
console.log("Query failed:", error);
}).finally(() => {
this.query_complete(query);
});
},
query_complete: function(query) {
if (this.running !== query) { // Possible if clear_queue() was called.
return;
}
let next_query = this.pending;
this.running = null;
this.pending = null;
if (next_query) {
this.send_query(next_query);
}
},
get_db: function(db_name) { // Creates it if needed.
if (typeof db_name !== "string") {
return null;
}
if (!this.all_dbs[db_name]) {
this.all_dbs[db_name] = Object.create(null);
}
return this.all_dbs[db_name];
},
new_entry: function(db_name, board) { // Creates a new (empty) entry in the database (to be populated elsewhere) and returns it.
let entry = {
type: db_name,
moves: {},
};
let db = this.get_db(db_name);
db[board.fen()] = entry;
return entry;
},
lookup: function(db_name, board) {
// Return the full entry for a position. When repeatedly called with the same params, this should
// return the same object (unless it changes of course). Returns null if not available.
let db = this.get_db(db_name);
if (db) { // Remember get_db() can return null.
let ret = db[board.fen()];
if (ret) {
return ret;
}
}
return null; // I guess we tend to like null over undefined. (Bad habit?)
},
set_ban: function(db_name) {
this.bans[db_name] = performance.now();
},
query_api(query) { // Returns a promise, which is solely used by the caller to attach some cleanup catch/finally()
if (this.lookup(query.db_name, query.board)) { // We already have a result for this board.
return Promise.resolve(); // Consider this case a satisfactory result.
}
if (this.bans[query.db_name]) {
if (performance.now() - this.bans[query.db_name] < 60000) { // No requests within 1 minute of the ban.
return Promise.resolve(); // Consider this case a satisfactory result.
}
}
let friendly_fen = query.board.fen(true);
let fen_for_web = ReplaceAll(friendly_fen, " ", "%20");
let url;
if (query.db_name === "chessdbcn") {
url = `https://www.chessdb.cn/cdb.php?action=queryall&json=1&board=${fen_for_web}`;
} else if (query.db_name === "lichess_masters") {
url = `https://explorer.lichess.org/masters?topGames=0&fen=${fen_for_web}`;
} else if (query.db_name === "lichess_plebs") {
url = `https://explorer.lichess.org/lichess?variant=standard&topGames=0&recentGames=0&fen=${fen_for_web}`;
} else {
return Promise.reject(new Error("Bad db_name"));
}
let fetch_options = {
headers: {"User-Agent": "Nibbler"}
};
if ((query.db_name === "lichess_masters" || query.db_name === "lichess_plebs") && config.lichess_token) {
fetch_options.headers["Authorization"] = `Bearer ${config.lichess_token}`;
}
return fetch(url, fetch_options).then(response => {
if (response.status === 429) { // rate limit hit
this.set_ban(query.db_name);
hub.set_special_message("429 Too Many Requests", "red", 5000); // relies on hub being in script/global scope, which it is
throw new Error("rate limited");
}
if (!response.ok) { // ok means status in range 200-299
throw new Error("response.ok was false");
}
return response.json();
}).then(raw_object => {
this.handle_response_object(query, raw_object);
});
},
handle_response_object: function(query, raw_object) {
let board = query.board;
let o = this.new_entry(query.db_name, board);
// If the raw_object is invalid, now's the time to return - after the empty object
// has been stored in the database, so we don't do this lookup again.
if (typeof raw_object !== "object" || raw_object === null || Array.isArray(raw_object.moves) === false) {
return; // This can happen e.g. if the position is checkmate.
}
// Our Lichess moves need to know the total number of games so they can return valid stats.
// While the total is available as raw_object.white + raw_object.black + raw_object.draws,
// it's probably better to sum up the items that we're given.
let lichess_position_total = 0;
if (query.db_name === "lichess_masters" || query.db_name === "lichess_plebs") {
for (let raw_item of raw_object.moves) {
lichess_position_total += raw_item.white + raw_item.black + raw_item.draws;
}
}
// Now add moves to the entry...
for (let raw_item of raw_object.moves) {
let move = raw_item.uci;
move = board.c960_castling_converter(move);
if (query.db_name === "chessdbcn") {
o.moves[move] = new_chessdbcn_move(board, raw_item);
} else if (query.db_name === "lichess_masters" || query.db_name === "lichess_plebs") {
o.moves[move] = new_lichess_move(board, raw_item, lichess_position_total);
}
}
// Note that even if we get no info, we still leave the empty object o in the database,
// and this allows us to know that we've done this search already.
},
};
// Below are some functions which use the info a server sends about a single move to create our
// own object containing just what we need (and with a prototype containing some useful methods).
function new_chessdbcn_move(board, raw_item) { // The object with info about a single move in a chessdbcn object.
let ret = Object.create(chessdbcn_move_props);
ret.active = board.active;
ret.score = raw_item.score / 100;
return ret;
}
let chessdbcn_move_props = {
text: function(pov) { // pov can be null for current
let score = this.score;
if ((pov === "w" && this.active === "b") || (pov === "b" && this.active === "w")) {
score = 0 - this.score;
}
let s = score.toFixed(2);
if (s !== "0.00" && s[0] !== "-") {
s = "+" + s;
}
return `API: ${s}`;
},
sort_score: function() {
return this.score;
},
};
function new_lichess_move(board, raw_item, position_total) { // The object with info about a single move in a lichess object.
let ret = Object.create(lichess_move_props);
ret.active = board.active;
ret.white = raw_item.white;
ret.black = raw_item.black;
ret.draws = raw_item.draws;
ret.total = raw_item.white + raw_item.draws + raw_item.black;
ret.position_total = position_total;
return ret;
}
let lichess_move_props = {
text: function(pov) { // pov can be null for current
let actual_pov = pov ? pov : this.active;
let wins = actual_pov === "w" ? this.white : this.black;
let ev = (wins + (this.draws / 2)) / this.total;
let win_string = (ev * 100).toFixed(1);
let weight_string = (100 * this.total / this.position_total).toFixed(0);
return `API win: ${win_string}% freq: ${weight_string}% [${NString(this.total)}]`;
},
sort_score: function() {
return this.total;
},
};
================================================
FILE: files/src/renderer/80_info.js
================================================
"use strict";
function NewInfoHandler() {
let ih = Object.create(null);
Object.assign(ih, info_misc_props);
Object.assign(ih, info_receiver_props);
Object.assign(ih, arrow_props);
Object.assign(ih, infobox_props);
// Array of possible one-click moves. Updated by draw_arrows(). Used elsewhere.
ih.one_click_moves = New2DArray(8, 8, null);
// Clickable elements in the infobox. Updated by draw_infobox(). Used elsewhere.
ih.info_clickers = [];
ih.info_clickers_node_id = null;
// Infobox stuff, used solely to skip redraws...
ih.last_drawn_node_id = null;
ih.last_drawn_version = null;
ih.last_drawn_highlight = null;
ih.last_drawn_highlight_class = null;
ih.last_drawn_length = 0;
ih.last_drawn_searchmoves = [];
ih.last_drawn_allow_inactive_focus = null;
ih.last_drawn_lookup_object = null;
// Info about engine cycles. These aren't reset even when the engine resets.
ih.engine_cycle = 0; // Count of "go" commands emitted. Since Engine can change, can't store this in Engine objects
ih.engine_subcycle = 0; // Count of how many times we have seen "multipv 1" - each time it's a new "block" of info
ih.ever_updated_a_table = false;
// Info about the current engine...
// Note that, when the engine is restarted, hub must call reset_engine_info() to fix these. A bit lame.
ih.engine_start_time = performance.now();
ih.engine_sent_info = false;
ih.engine_sent_q = false;
ih.engine_sent_errors = false;
ih.error_time = 0;
ih.error_log = "";
ih.next_vms_order_int = 1;
return ih;
}
let info_misc_props = {
reset_engine_info: function() {
this.engine_start_time = performance.now();
this.engine_sent_info = false;
this.engine_sent_q = false;
this.engine_sent_errors = false;
this.error_time = 0;
this.error_log = "";
this.next_vms_order_int = 1;
},
displaying_error_log: function() {
// Recent error...
if (this.engine_sent_errors && performance.now() - this.error_time < 10000) {
return true;
}
// Engine hasn't yet sent info, and was recently started...
if (!this.engine_sent_info) {
if (performance.now() - this.engine_start_time < 5000) {
return true;
}
}
// We have never updated a table (meaning we never received useful info from an engine)
// and we aren't displaying API info...
if (!this.ever_updated_a_table && !config.looker_api) {
return true;
}
return false;
},
};
let info_receiver_props = {
err_receive: function(s) {
if (typeof s !== "string") {
return;
}
if (this.error_log.length > 50000) {
return;
}
let s_low = s.toLowerCase();
if (s_low.includes("warning") || s_low.includes("error") || s_low.includes("unknown") || s_low.includes("failed") || s_low.includes("exception")) {
this.engine_sent_errors = true;
this.error_log += `${s}
`;
this.error_time = performance.now();
} else {
this.error_log += `${s}
`;
}
},
receive: function(engine, search, s) {
let node = search.node;
if (typeof s !== "string" || !node || node.destroyed) {
return;
}
let board = node.board;
if (s.startsWith("info") && s.includes(" pv ") && ((!s.includes("lowerbound") && !s.includes("upperbound")) || config.accept_bounds)) {
if (config.log_info_lines) Log("< " + s);
// info depth 8 seldepth 31 time 3029 nodes 23672 score cp 27 wdl 384 326 290 nps 7843 tbhits 0 multipv 1
// pv d2d4 g8f6 c2c4 e7e6 g1f3 d7d5 b1c3 f8b4 c1g5 d5c4 e2e4 c7c5 f1c4 h7h6 g5f6 d8f6 e1h1 c5d4 e4e5 f6d8 c3e4
let infovals = InfoValMany(s, ["pv", "cp", "mate", "multipv", "nodes", "nps", "time", "depth", "seldepth", "tbhits"]);
let tmp;
let move_info;
let move = infovals["pv"];
move = board.c960_castling_converter(move);
if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move.
move_info = node.table.moveinfo[move];
} else { // We don't.
if (board.illegal(move)) {
if (config.log_illegal_moves) {
Log(`INVALID / ILLEGAL MOVE RECEIVED: ${move}`);
}
return;
}
move_info = NewInfo(board, move);
node.table.moveinfo[move] = move_info;
}
let move_cycle_pre_update = move_info.cycle;
let move_depth_pre_update = move_info.depth;
// ---------------------------------------------------------------------------------------------------------------------
if (!engine.leelaish) {
move_info.clear_stats(); // The stats we get this way are all that the engine has, so clear everything.
}
move_info.leelaish = engine.leelaish;
this.engine_sent_info = true; // After the move legality check; i.e. we want REAL info
this.ever_updated_a_table = true;
node.table.version++;
node.table.limit = search.limit;
move_info.cycle = this.engine_cycle;
move_info.__touched = true;
// ---------------------------------------------------------------------------------------------------------------------
let did_set_q_from_mate = false;
tmp = parseInt(infovals["cp"], 10);
if (Number.isNaN(tmp) === false) {
move_info.cp = tmp;
if (this.engine_sent_q === false) {
move_info.q = QfromPawns(tmp / 100); // Potentially overwritten later by the better QfromWDL()
}
move_info.mate = 0; // Engines will send one of cp or mate, so mate gets reset when receiving cp
}
tmp = parseInt(infovals["mate"], 10);
if (Number.isNaN(tmp) === false) {
move_info.mate = tmp;
if (tmp !== 0) {
move_info.q = tmp > 0 ? 1 : -1;
move_info.cp = tmp > 0 ? 32000 : -32000;
did_set_q_from_mate = true;
}
}
tmp = parseInt(infovals["multipv"], 10);
if (Number.isNaN(tmp) === false) {
move_info.multipv = tmp;
if (tmp === 1) {
this.engine_subcycle++;
}
} else {
this.engine_subcycle++;
}
move_info.subcycle = this.engine_subcycle;
tmp = parseInt(infovals["nodes"], 10);
if (Number.isNaN(tmp) === false) {
move_info.uci_nodes = tmp;
node.table.nodes = tmp;
}
tmp = parseInt(infovals["nps"], 10);
if (Number.isNaN(tmp) === false) {
node.table.nps = tmp; // Note this is stored in the node.table, not the move_info
}
tmp = parseInt(infovals["time"], 10);
if (Number.isNaN(tmp) === false) {
node.table.time = tmp; // Note this is stored in the node.table, not the move_info
}
tmp = parseInt(infovals["tbhits"], 10);
if (Number.isNaN(tmp) === false) {
node.table.tbhits = tmp; // Note this is stored in the node.table, not the move_info
}
tmp = parseInt(infovals["depth"], 10);
if (Number.isNaN(tmp) === false) {
move_info.depth = tmp;
}
tmp = parseInt(infovals["seldepth"], 10);
if (Number.isNaN(tmp) === false) {
move_info.seldepth = tmp;
}
move_info.wdl = InfoWDL(s);
if (this.engine_sent_q === false && !did_set_q_from_mate && Array.isArray(move_info.wdl)) {
move_info.q = QfromWDL(move_info.wdl);
}
// If the engine isn't respecting Chess960 castling format, the PV
// may contain old-fashioned castling moves...
let new_pv = InfoPV(s);
C960_PV_Converter(new_pv, board);
if (CompareArrays(new_pv, move_info.pv) === false) {
if (!board.sequence_illegal(new_pv)) {
if (move_cycle_pre_update === move_info.cycle
&& ArrayStartsWith(move_info.pv, new_pv)
&& move_depth_pre_update >= move_info.depth - 1
) {
// Skip the update. This partially mitigates Stockfish sending unresolved PVs.
// We don't skip the update if the old PV is too old - issue noticed by Nagisa.
} else {
move_info.set_pv(new_pv);
}
} else {
move_info.set_pv([move]);
}
}
} else if (s.startsWith("info string") && !s.includes("NNUE evaluation")) {
if (config.log_info_lines) Log("< " + s);
// info string d2d4 (293 ) N: 12005 (+169) (P: 22.38%) (WL: 0.09480) (D: 0.326)
// (M: 7.4) (Q: 0.09480) (U: 0.01211) (Q+U: 0.10691) (V: 0.0898)
// Ceres has been known to send these in Euro decimal format e.g. Q: 0,094
// We'll have to replace all commas...
s = ReplaceAll(s, ",", ".");
let infovals = InfoValMany(s, ["string", "N:", "(D:", "(U:", "(Q+U:", "(S:", "(P:", "(Q:", "(V:", "(M:"]);
let tmp;
let move_info;
let move = infovals["string"];
if (move === "node") { // Mostly ignore these lines, but...
this.next_vms_order_int = 1; // ...use them to note that the VerboseMoveStats have completed. A bit sketchy?
tmp = parseInt(infovals["N:"], 10);
if (Number.isNaN(tmp) === false) {
node.table.nodes = tmp; // ...and use this line to ensure a valid nodes count for the table. (Mostly helps with Ceres.)
}
return;
}
move = board.c960_castling_converter(move);
if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move.
move_info = node.table.moveinfo[move];
} else { // We don't.
if (board.illegal(move)) {
if (config.log_illegal_moves) {
Log(`INVALID / ILLEGAL MOVE RECEIVED: ${move}`);
}
return;
}
move_info = NewInfo(board, move);
node.table.moveinfo[move] = move_info;
}
// ---------------------------------------------------------------------------------------------------------------------
engine.leelaish = true; // Note this isn't the main way engine.leelaish gets set (because reasons)
move_info.leelaish = true;
this.engine_sent_info = true; // After the move legality check; i.e. we want REAL info
this.ever_updated_a_table = true;
node.table.version++;
node.table.limit = search.limit;
// move_info.cycle = this.engine_cycle; // No... we get VMS lines even when excluded by searchmoves.
// move_info.subcycle = this.engine_subcycle;
move_info.__touched = true;
// ---------------------------------------------------------------------------------------------------------------------
move_info.vms_order = this.next_vms_order_int++;
tmp = parseInt(infovals["N:"], 10);
if (Number.isNaN(tmp) === false) {
move_info.n = tmp;
}
tmp = parseFloat(infovals["(U:"]);
if (Number.isNaN(tmp) === false) {
move_info.u = tmp;
}
tmp = parseFloat(infovals["(Q+U:"]); // Q+U, old name for S
if (Number.isNaN(tmp) === false) {
move_info.s = tmp;
}
tmp = parseFloat(infovals["(S:"]);
if (Number.isNaN(tmp) === false) {
move_info.s = tmp;
}
tmp = parseFloat(infovals["(P:"]); // P, parseFloat will ignore the trailing %
if (Number.isNaN(tmp) === false) {
move_info.p = tmp;
}
tmp = parseFloat(infovals["(Q:"]);
if (Number.isNaN(tmp) === false) {
this.engine_sent_q = true;
move_info.q = tmp;
}
tmp = parseFloat(infovals["(V:"]);
if (Number.isNaN(tmp) === false) {
move_info.v = tmp;
} else {
move_info.v = null; // V sometimes is -.----- (we used to not do anything here, preserving any old (but confusing) value)
}
tmp = parseFloat(infovals["(M:"]);
if (Number.isNaN(tmp) === false) {
move_info.m = tmp;
} else {
move_info.m = null; // M sometimes is -.----- (we used to not do anything here, preserving any old (but confusing) value)
}
} else if (s.startsWith("info") && s.includes(" pv ") && (s.includes("lowerbound") || s.includes("upperbound"))) {
if (config.log_info_lines) Log("< " + s);
let infovals = InfoValMany(s, ["pv", "multipv"]);
let tmp;
let move_info;
let move = infovals["pv"];
move = board.c960_castling_converter(move);
if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move.
move_info = node.table.moveinfo[move];
}
if (move_info) {
tmp = parseInt(infovals["multipv"], 10);
if (Number.isNaN(tmp) === false) {
move_info.multipv = tmp;
move_info.subcycle = this.engine_subcycle;
}
}
} else {
if (config.log_info_lines && config.log_useless_info) Log("< " + s);
}
},
};
================================================
FILE: files/src/renderer/81_arrows.js
================================================
"use strict";
let arrow_props = {
draw_arrows: function(node, specific_source, show_move) { // If not nullish, specific_source is a Point() and show_move is a string
// Function is responsible for updating the one_click_moves array.
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
this.one_click_moves[x][y] = null;
}
}
if (!config.arrows_enabled || !node || node.destroyed) {
return;
}
let full_list = SortedMoveInfo(node);
if (full_list.length === 0) { // Keep this test early so we can assume full_list[0] exists later.
return;
}
let best_info = full_list[0]; // Note that, since we may filter the list, it might not contain best_info later.
let info_list = [];
let arrows = [];
let heads = [];
let mode;
let show_move_was_forced = false; // Will become true if the show_move is only in the list because of the show_move arg
let show_move_head = null;
if (specific_source) {
mode = "specific";
} else if (full_list[0].__ghost) {
mode = "ghost";
} else if (full_list[0].__touched === false) {
mode = "untouched";
} else if (full_list[0].leelaish === false) {
mode = "ab";
} else {
mode = "normal";
}
switch (mode) {
case "normal":
info_list = full_list;
break;
case "ab":
for (let info of full_list) {
if (info.__touched && info.subcycle >= full_list[0].subcycle) {
info_list.push(info);
} else if (info.move === show_move) {
info_list.push(info);
show_move_was_forced = true;
}
}
break;
case "ghost":
for (let info of full_list) {
if (info.__ghost) {
info_list.push(info);
} else if (info.move === show_move) {
info_list.push(info);
show_move_was_forced = true;
}
}
break;
case "untouched":
for (let info of full_list) {
if (info.move === show_move) {
info_list.push(info);
show_move_was_forced = true;
}
}
break;
case "specific":
for (let info of full_list) {
if (info.move.slice(0, 2) === specific_source.s) {
info_list.push(info);
}
}
break;
}
// ------------------------------------------------------------------------------------------------------------
for (let i = 0; i < info_list.length; i++) {
let loss = 0;
if (typeof best_info.q === "number" && typeof info_list[i].q === "number") {
loss = best_info.value() - info_list[i].value();
}
let ok = true;
// Filter for normal (Leelaish) mode...
if (mode === "normal") {
if (config.arrow_filter_type === "top") {
if (i !== 0) {
ok = false;
}
}
if (config.arrow_filter_type === "N") {
if (typeof info_list[i].n !== "number" || info_list[i].n === 0) {
ok = false;
} else {
let n_fraction = info_list[i].n / node.table.nodes;
if (n_fraction < config.arrow_filter_value) {
ok = false;
}
}
}
// Moves proven to lose...
if (typeof info_list[i].u === "number" && info_list[i].u === 0 && info_list[i].value() === 0) {
if (config.arrow_filter_type !== "all") {
ok = false;
}
}
// If the show_move would be filtered out, note that fact...
if (!ok && info_list[i].move === show_move) {
show_move_was_forced = true;
}
}
// Filter for ab mode...
// Note that we don't set show_move_was_forced for ab mode.
// If it wasn't already set, then we have good info for this move.
if (mode === "ab") {
if (loss >= config.ab_filter_threshold) {
ok = false;
}
}
// Go ahead, if the various tests don't filter the move out...
if (ok || i === 0 || info_list[i].move === show_move) {
let [x1, y1] = XY(info_list[i].move.slice(0, 2));
let [x2, y2] = XY(info_list[i].move.slice(2, 4));
let colour;
if (info_list[i].move === show_move && config.next_move_unique_colour) {
colour = config.actual_move_colour;
} else if (info_list[i].move === show_move && show_move_was_forced) {
colour = config.terrible_colour;
} else if (info_list[i].__touched === false) {
colour = config.terrible_colour;
} else if (info_list[i] === best_info) {
colour = config.best_colour;
} else if (loss < config.bad_move_threshold) {
colour = config.good_colour;
} else if (loss < config.terrible_move_threshold) {
colour = config.bad_colour;
} else {
colour = config.terrible_colour;
}
let x_head_adjustment = 0; // Adjust head of arrow for castling moves...
let normal_castling_flag = false;
if (node.board && node.board.colour(Point(x1, y1)) === node.board.colour(Point(x2, y2))) {
// So the move is a castling move (reminder: as of 1.1.6 castling format is king-onto-rook).
if (node.board.normalchess) {
normal_castling_flag = true; // ...and we are playing normal Chess (not 960).
}
if (x2 > x1) {
x_head_adjustment = normal_castling_flag ? -1 : -0.5;
} else {
x_head_adjustment = normal_castling_flag ? 2 : 0.5;
}
}
arrows.push({
colour: colour,
x1: x1,
y1: y1,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
// If there is no one_click_move set for the target square, then set it
// and also set an arrowhead to be drawn later.
if (normal_castling_flag) {
if (!this.one_click_moves[x2 + x_head_adjustment][y2]) {
heads.push({
colour: colour,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
this.one_click_moves[x2 + x_head_adjustment][y2] = info_list[i].move;
if (info_list[i].move === show_move) {
show_move_head = heads[heads.length - 1];
}
}
} else {
if (!this.one_click_moves[x2][y2]) {
heads.push({
colour: colour,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
this.one_click_moves[x2][y2] = info_list[i].move;
if (info_list[i].move === show_move) {
show_move_head = heads[heads.length - 1];
}
}
}
}
}
// It looks best if the longest arrows are drawn underneath. Manhattan distance is good enough.
// For the sake of displaying the best pawn promotion (of the 4 possible), sort ties are broken
// by node counts, with lower drawn first. [Eh, what about Stockfish? Meh, it doesn't affect
// the heads, merely the colour of the lines, so it's not a huge problem I think.]
arrows.sort((a, b) => {
if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) < Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) {
return 1;
}
if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) > Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) {
return -1;
}
if (a.info.n < b.info.n) {
return -1;
}
if (a.info.n > b.info.n) {
return 1;
}
return 0;
});
boardctx.lineWidth = config.arrow_width;
boardctx.textAlign = "center";
boardctx.textBaseline = "middle";
boardctx.font = config.board_font;
for (let o of arrows) {
let cc1 = CanvasCoords(o.x1, o.y1);
let cc2 = CanvasCoords(o.x2, o.y2);
if (o.info.move === show_move && config.next_move_outline) { // Draw the outline at the layer just below the actual arrow.
boardctx.strokeStyle = "black";
boardctx.fillStyle = "black";
boardctx.lineWidth = config.arrow_width + 4;
boardctx.beginPath();
boardctx.moveTo(cc1.cx, cc1.cy);
boardctx.lineTo(cc2.cx, cc2.cy);
boardctx.stroke();
boardctx.lineWidth = config.arrow_width;
if (show_move_head) { // This is the best layer to draw the head outline.
boardctx.beginPath();
boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius + 2, 0, 2 * Math.PI);
boardctx.fill();
}
}
boardctx.strokeStyle = o.colour;
boardctx.fillStyle = o.colour;
boardctx.beginPath();
boardctx.moveTo(cc1.cx, cc1.cy);
boardctx.lineTo(cc2.cx, cc2.cy);
boardctx.stroke();
}
for (let o of heads) {
let cc2 = CanvasCoords(o.x2, o.y2);
boardctx.fillStyle = o.colour;
boardctx.beginPath();
boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius, 0, 2 * Math.PI);
boardctx.fill();
boardctx.fillStyle = "black";
let s = "?";
switch (config.arrowhead_type) {
case 0:
s = o.info.value_string(0, config.ev_pov);
if (s === "100" && o.info.q < 1.0) {
s = "99"; // Don't round up to 100.
}
break;
case 1:
if (node.table.nodes > 0) {
s = (100 * o.info.n / node.table.nodes).toFixed(0);
}
break;
case 2:
if (o.info.p > 0) {
s = o.info.p.toFixed(0);
}
break;
case 3:
s = o.info.multipv;
break;
case 4:
if (typeof o.info.m === "number") {
s = o.info.m.toFixed(0);
}
break;
default:
s = "!";
break;
}
if (o.info.__touched === false) {
s = "?";
}
if (show_move_was_forced && o.info.move === show_move) {
s = "?";
}
boardctx.fillText(s, cc2.cx, cc2.cy + 1);
}
draw_arrows_last_mode = mode; // For debugging only.
},
// ----------------------------------------------------------------------------------------------------------
// We have a special function for the book explorer mode. Explorer mode is very nicely isolated from the rest
// of the app. The info_list here is just a list of objects each containing only "move" and "weight" - where
// the weights have been normalised to the 0-1 scale and the list has been sorted.
//
// Note that info_list here MUST NOT BE MODIFIED.
draw_explorer_arrows: function(node, info_list, specific_source) { // If not nullish, specific_source is a Point()
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
this.one_click_moves[x][y] = null;
}
}
if (!node || node.destroyed) {
return;
}
let arrows = [];
let heads = [];
for (let i = 0; i < info_list.length; i++) {
if (specific_source && specific_source.s !== info_list[i].move.slice(0, 2)) {
continue;
}
let [x1, y1] = XY(info_list[i].move.slice(0, 2));
let [x2, y2] = XY(info_list[i].move.slice(2, 4));
let colour = i === 0 ? config.best_colour : config.good_colour;
let x_head_adjustment = 0; // Adjust head of arrow for castling moves...
let normal_castling_flag = false;
if (node.board && node.board.colour(Point(x1, y1)) === node.board.colour(Point(x2, y2))) {
if (node.board.normalchess) {
normal_castling_flag = true; // ...and we are playing normal Chess (not 960).
}
if (x2 > x1) {
x_head_adjustment = normal_castling_flag ? -1 : -0.5;
} else {
x_head_adjustment = normal_castling_flag ? 2 : 0.5;
}
}
arrows.push({
colour: colour,
x1: x1,
y1: y1,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
// If there is no one_click_move set for the target square, then set it
// and also set an arrowhead to be drawn later.
if (normal_castling_flag) {
if (!this.one_click_moves[x2 + x_head_adjustment][y2]) {
heads.push({
colour: colour,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
this.one_click_moves[x2 + x_head_adjustment][y2] = info_list[i].move;
}
} else {
if (!this.one_click_moves[x2][y2]) {
heads.push({
colour: colour,
x2: x2 + x_head_adjustment,
y2: y2,
info: info_list[i]
});
this.one_click_moves[x2][y2] = info_list[i].move;
}
}
}
arrows.sort((a, b) => {
if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) < Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) {
return 1;
}
if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) > Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) {
return -1;
}
return 0;
});
boardctx.lineWidth = config.arrow_width;
boardctx.textAlign = "center";
boardctx.textBaseline = "middle";
boardctx.font = config.board_font;
for (let o of arrows) {
let cc1 = CanvasCoords(o.x1, o.y1);
let cc2 = CanvasCoords(o.x2, o.y2);
boardctx.strokeStyle = o.colour;
boardctx.fillStyle = o.colour;
boardctx.beginPath();
boardctx.moveTo(cc1.cx, cc1.cy);
boardctx.lineTo(cc2.cx, cc2.cy);
boardctx.stroke();
}
for (let o of heads) {
let cc2 = CanvasCoords(o.x2, o.y2);
boardctx.fillStyle = o.colour;
boardctx.beginPath();
boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius, 0, 2 * Math.PI);
boardctx.fill();
boardctx.fillStyle = "black";
let s = "?";
if (typeof o.info.weight === "number") {
s = (100 * o.info.weight).toFixed(0);
}
boardctx.fillText(s, cc2.cx, cc2.cy + 1);
}
}
};
// For debugging...
let draw_arrows_last_mode = null;
================================================
FILE: files/src/renderer/82_infobox.js
================================================
"use strict";
let infobox_props = {
draw_infobox: function(node, mouse_point, active_square, active_colour, hoverdraw_div, allow_inactive_focus, lookup_object) {
let searchmoves = node.searchmoves;
if (this.displaying_error_log()) {
infobox.innerHTML = this.error_log;
this.last_drawn_version = null;
return;
}
if (!node || node.destroyed) {
return;
}
let info_list;
if (node.terminal_reason()) {
info_list = [];
} else {
info_list = SortedMoveInfo(node);
}
// A lookup_object should always have type (string) and moves (object).
let ltype = lookup_object ? lookup_object.type : null;
let lookup_moves = lookup_object ? lookup_object.moves : null;
// If we are using an online API, and the list has some "untouched" info, we
// may be able to sort them using the API info.
if (ltype === "chessdbcn" || ltype === "lichess_masters" || ltype === "lichess_plebs") {
let touched_list = [];
let untouched_list = [];
for (let info of info_list) {
if (info.__touched) {
touched_list.push(info);
} else {
untouched_list.push(info);
}
}
const a_is_best = -1;
const b_is_best = 1;
untouched_list.sort((a, b) => {
if (lookup_moves[a.move] && !lookup_moves[b.move]) return a_is_best;
if (!lookup_moves[a.move] && lookup_moves[b.move]) return b_is_best;
if (!lookup_moves[a.move] && !lookup_moves[b.move]) return 0;
return lookup_moves[b.move].sort_score() - lookup_moves[a.move].sort_score();
});
info_list = touched_list.concat(untouched_list);
}
let best_subcycle = info_list.length > 0 ? info_list[0].subcycle : 0;
if (best_subcycle === 0) { // Because all info was autopopulated
best_subcycle = -1; // Causes all info to be gray
}
if (typeof config.max_info_lines === "number" && config.max_info_lines > 0) { // Hidden option, request of rwbc
info_list = info_list.slice(0, config.max_info_lines);
}
// We might be highlighting some div...
let highlight_move = null;
let highlight_class = null;
// We'll highlight it if it's a valid OCM *and* clicking there now would make it happen...
if (mouse_point && this.one_click_moves[mouse_point.x][mouse_point.y]) {
if (!active_square || this.one_click_moves[mouse_point.x][mouse_point.y].slice(0, 2) === active_square.s) {
highlight_move = this.one_click_moves[mouse_point.x][mouse_point.y];
highlight_class = "ocm_highlight";
}
}
if (typeof hoverdraw_div === "number" && hoverdraw_div >= 0 && hoverdraw_div < info_list.length) {
highlight_move = info_list[hoverdraw_div].move;
highlight_class = "hover_highlight";
}
// We cannot skip the draw if...
let no_skip_reasons = [];
if (node.id !== this.last_drawn_node_id) no_skip_reasons.push("node");
if (node.table.version !== this.last_drawn_version) no_skip_reasons.push("table version");
if (highlight_move !== this.last_drawn_highlight_move) no_skip_reasons.push("highlight move");
if (highlight_class !== this.last_drawn_highlight_class) no_skip_reasons.push("highlight class");
if (info_list.length !== this.last_drawn_length) no_skip_reasons.push("info list length");
if (allow_inactive_focus !== this.last_drawn_allow_inactive_focus) no_skip_reasons.push("allow inactive focus");
if (CompareArrays(searchmoves, this.last_drawn_searchmoves) === false) no_skip_reasons.push("searchmoves");
if (lookup_object !== this.last_drawn_lookup_object) no_skip_reasons.push("lookup object");
draw_infobox_no_skip_reasons = no_skip_reasons.join(", "); // For debugging only.
if (no_skip_reasons.length === 0) {
draw_infobox_total_skips++;
return;
}
this.last_drawn_node_id = node.id;
this.last_drawn_version = node.table.version;
this.last_drawn_highlight_move = highlight_move;
this.last_drawn_highlight_class = highlight_class;
this.last_drawn_length = info_list.length;
this.last_drawn_allow_inactive_focus = allow_inactive_focus;
this.last_drawn_searchmoves = Array.from(searchmoves);
this.last_drawn_lookup_object = lookup_object;
this.info_clickers = [];
this.info_clickers_node_id = node.id;
let substrings = [];
let clicker_index = 0;
let div_index = 0;
for (let info of info_list) {
// The div containing the PV etc...
let divclass = "infoline";
if (info.subcycle !== best_subcycle && !config.never_grayout_infolines) {
divclass += " " + "gray";
}
if (info.move === highlight_move) {
divclass += " " + highlight_class;
}
substrings.push(``);
// The "focus" button...
if (config.searchmoves_buttons) {
if (searchmoves.includes(info.move)) {
substrings.push(`${config.focus_on_text} `);
} else {
if (allow_inactive_focus) {
substrings.push(`${config.focus_off_text} `);
}
}
}
// The value...
let value_string = "?";
if (config.show_cp) {
if (typeof info.mate === "number" && info.mate !== 0) {
value_string = info.mate_string(config.cp_pov);
} else {
value_string = info.cp_string(config.cp_pov);
}
} else {
value_string = info.value_string(1, config.ev_pov);
if (value_string !== "?") {
value_string += "%";
}
}
if (info.subcycle === best_subcycle || config.never_grayout_infolines) {
substrings.push(`${value_string} `);
} else {
substrings.push(`${value_string} `);
}
// The PV...
let colour = active_colour;
let movenum = node.board.fullmove; // Only matters for config.infobox_pv_move_numbers
let nice_pv = info.nice_pv();
for (let i = 0; i < nice_pv.length; i++) {
let spanclass = "";
if (info.subcycle === best_subcycle || config.never_grayout_infolines) {
spanclass = colour === "w" ? "white" : "pink";
}
if (nice_pv[i].includes("O-O")) {
spanclass += (spanclass.length > 0) ? " nobr" : "nobr";
}
let numstring = "";
if (config.infobox_pv_move_numbers) {
if (colour === "w") {
numstring = `${movenum}. `;
} else if (colour === "b" && i === 0) {
numstring = `${movenum}... `;
}
}
substrings.push(`${numstring}${nice_pv[i]} `);
this.info_clickers.push({
move: info.pv[i],
is_start: i === 0,
is_end: i === nice_pv.length - 1,
});
colour = OppositeColour(colour);
if (colour === "w") {
movenum++;
}
}
// The extra stats...
let extra_stat_strings = [];
if (info.__touched) {
let stats_list = info.stats_list(
{
n: config.show_n,
n_abs: config.show_n_abs,
depth: config.show_depth,
wdl: config.show_wdl,
wdl_pov: config.wdl_pov,
p: config.show_p,
m: config.show_m,
v: config.show_v,
q: config.show_q,
u: config.show_u,
s: config.show_s,
}, node.table.nodes);
extra_stat_strings = extra_stat_strings.concat(stats_list);
}
if (config.looker_api) {
let api_string = "API: ?";
if (ltype && lookup_moves) {
let pov = null;
if (ltype === "chessdbcn") {
pov = config.cp_pov;
} else if (ltype === "lichess_masters" || ltype === "lichess_plebs") {
pov = config.ev_pov;
}
let o = lookup_moves[info.move];
if (typeof o === "object" && o !== null) {
api_string = o.text(pov);
}
}
extra_stat_strings.push(api_string);
}
if (extra_stat_strings.length > 0) {
if (config.infobox_stats_newline) {
substrings.push("
");
}
substrings.push(`(${extra_stat_strings.join(', ')})`);
}
// Close the whole div...
substrings.push("
");
}
infobox.innerHTML = substrings.join("");
},
must_draw_infobox: function() {
this.last_drawn_version = null;
},
clickers_are_valid_for_node: function(node) {
if (!node || !this.info_clickers_node_id) {
return false;
}
return node.id === this.info_clickers_node_id;
},
moves_from_click_n: function(n, desired_length = null) {
if (typeof n !== "number" || Number.isNaN(n)) {
return [];
}
if (!this.info_clickers || n < 0 || n >= this.info_clickers.length) {
return [];
}
let move_list = [];
// Work backwards until we get to the start of the line...
for (let i = n; i >= 0; i--) {
let object = this.info_clickers[i];
move_list.push(object.move);
if (object.is_start) {
break;
}
}
move_list.reverse();
// If a PV length is specified, either truncate or extend as needed...
if (typeof desired_length === "number") {
if (move_list.length > desired_length) {
move_list = move_list.slice(0, desired_length);
} else if (move_list.length < desired_length) {
for (let i = n + 1; i < this.info_clickers.length; i++) {
let object = this.info_clickers[i];
if (object.is_start) {
break;
}
move_list.push(object.move); // Note the different order of stataments compared to the above.
if (move_list.length >= desired_length) {
break;
}
}
}
}
return move_list;
},
};
// For debugging...
let draw_infobox_total_skips = 0;
let draw_infobox_no_skip_reasons = "";
================================================
FILE: files/src/renderer/83_statusbox.js
================================================
"use strict";
function NewStatusHandler() {
let sh = Object.create(null);
sh.special_message = null;
sh.special_message_class = "yellow";
sh.special_message_timeout = performance.now();
sh.set_special_message = function(s, css_class, duration) {
if (!css_class) css_class = "yellow";
if (!duration) duration = 3000;
this.special_message = s;
this.special_message_class = css_class;
this.special_message_timeout = performance.now() + duration;
};
sh.draw_statusbox = function(node, engine, analysing_other, loading_message, book_is_loaded) {
if (loading_message) {
statusbox.innerHTML = `${loading_message} (abort?)`;
} else if (config.show_engine_state) {
let cl;
let status;
if (engine.search_running.node && engine.search_running === engine.search_desired) {
cl = "green";
status = "running";
} else if (engine.search_running !== engine.search_desired) {
cl = "yellow";
status = "desync";
} else {
cl = "yellow";
status = "stopped";
}
statusbox.innerHTML =
`${status}, ` +
`${config.behaviour}, ` +
`${engine.last_send}`;
} else if (!engine.ever_received_uciok) {
statusbox.innerHTML = `Awaiting uciok from engine`;
} else if (!engine.ever_received_readyok) {
statusbox.innerHTML = `Awaiting readyok from engine`;
} else if (this.special_message && performance.now() < this.special_message_timeout) {
statusbox.innerHTML = `${this.special_message}`;
} else if (engine.unresolved_stop_time && performance.now() - engine.unresolved_stop_time > 500) {
statusbox.innerHTML = `${messages.desync}`;
} else if (analysing_other) {
statusbox.innerHTML = `Locked to ${analysing_other} (return?)`;
} else if (node.terminal_reason()) {
statusbox.innerHTML = `${node.terminal_reason()}`;
} else if (!node || node.destroyed) {
statusbox.innerHTML = `draw_statusbox - !node || node.destroyed`;
} else {
let status_string = "";
if (config.behaviour === "halt" && !engine.search_running.node) {
status_string += `HALTED (go?) `;
} else if (config.behaviour === "halt" && engine.search_running.node) {
status_string += `HALTING... `;
} else if (config.behaviour === "analysis_locked") {
status_string += `Locked! `;
} else if (config.behaviour === "play_white" && node.board.active !== "w") {
status_string += `YOUR MOVE `;
} else if (config.behaviour === "play_black" && node.board.active !== "b") {
status_string += `YOUR MOVE `;
} else if (config.behaviour === "self_play") {
status_string += `Self-play! `;
} else if (config.behaviour === "auto_analysis") {
status_string += `Auto-eval! `;
} else if (config.behaviour === "back_analysis") {
status_string += `Back-eval! `;
} else if (config.behaviour === "analysis_free") {
if (hub.engine.sent_options.contempt !== undefined && hub.engine.sent_options.contempt !== "0") {
status_string += `Contempt active! `;
} else {
status_string += `ANALYSIS (halt?) `;
}
}
if (config.book_explorer) {
let warn = book_is_loaded ? "" : " (No book loaded)";
status_string += `Book frequency arrows only!${warn}`;
} else if (config.lichess_explorer) {
let warn = (config.looker_api === "lichess_masters" || config.looker_api === "lichess_plebs") ? "" : " (API not selected)";
status_string += `Lichess frequency arrows only!${warn}`;
} else {
status_string += `${NString(node.table.nodes)} ${node.table.nodes === 1 ? "node" : "nodes"}`;
status_string += `, ${DurationString(node.table.time)} (N/s: ${NString(node.table.nps)})`;
if (engineconfig[engine.filepath].options["SyzygyPath"] || node.table.tbhits > 0) {
status_string += `, ${NString(node.table.tbhits)} ${node.table.tbhits === 1 ? "tbhit" : "tbhits"}`;
}
status_string += ``;
if (!engine.search_running.node && engine.search_completed.node === node) {
let stoppedtext = "";
if (config.behaviour !== "halt") {
stoppedtext = ` (stopped)`;
}
/*
// The following doesn't make sense if a time limit rather than a move limit is in force.
if (typeof engineconfig[engine.filepath].search_nodes === "number" && engineconfig[engine.filepath].search_nodes > 0) {
if (node.table.nodes >= engineconfig[engine.filepath].search_nodes) {
stoppedtext = ` (limit met)`;
}
}
*/
status_string += stoppedtext;
}
}
statusbox.innerHTML = status_string;
}
};
return sh;
}
================================================
FILE: files/src/renderer/90_engine.js
================================================
"use strict";
/*
We are in one of these states (currently implicit in the logic):
1. Inactive
2. Running a search
3. Changing the search
4. Ending the search
(1) Inactive................................................................................
A "bestmove" should not arrive. If the user wants to start a search, we send it and
enter state 2.
(2) Running a search........................................................................
A "bestmove" might arrive, in which case the search ends and we go into state 1. The
"bestmove" line must be passed to hub.receive_bestmove().
Alternatively, the user may demand a search with new parameters, in which case we send
"stop" and enter state 3. Or the user may halt, in which case we send "stop" and enter
state 4.
(3) Changing the search.....................................................................
A "stop" has been sent and we are waiting for a "bestmove" response. When it arrives,
we can send the new search and go back to state 2. The "bestmove" line itself can be
discarded since it is not relevant to the desired search.
In state 3, if the user changes the desired search, we simply replace the old desired
search (which never started) with the new desired search (which may be the null search,
in which case we have entered state 4).
(4) Ending the search.......................................................................
Just like state 3, except the desired search is the null search. When a "bestmove"
arrives, we go to state 1.
*/
const GUI_WANTS_TO_KNOW = ["Backend", "EvalFile", "WeightsFile", "SyzygyPath", "Threads", "Hash", "MultiPV",
"ContemptMode", "Contempt", "WDLCalibrationElo", "WDLEvalObjectivity", "ScoreType", "Temperature", "TempDecayMoves"];
let NoSearch = Object.freeze({
node: null,
limit: null,
limit_by_time: false,
searchmoves: Object.freeze([])
});
function SearchParams(node = null, limit = null, limit_by_time = false, searchmoves = null) {
if (!node) return NoSearch;
let validated;
if (Array.isArray(searchmoves)) {
validated = node.validate_searchmoves(searchmoves); // returns a new array
} else {
validated = [];
}
Object.freeze(validated); // under no circumstances refactor this to freeze the original searchmoves
return Object.freeze({
node: node,
limit: limit,
limit_by_time: limit_by_time,
searchmoves: validated
});
}
function NewEngine(hub) {
let eng = Object.create(null);
eng.hub = hub;
eng.exe = null;
eng.scanner = null;
eng.err_scanner = null;
eng.filepath = ""; // Used to decide what entry in engineconfig to use. Start as "", which has defaults for the dummy engine.
eng.last_send = null;
eng.unresolved_stop_time = null;
eng.ever_received_uciok = false;
eng.ever_received_readyok = false;
eng.have_quit = false;
eng.suppress_cycle_info = null; // Stupid hack to allow "forget all analysis" to work; info lines from this cycle are ignored.
eng.known_options = Object.create(null); // Keys are always lowercase.
eng.sent_options = Object.create(null); // Keys are always lowercase. Values are always strings.
eng.setoption_queue = [];
eng.warn_send_fail = true;
eng.leelaish = false; // Most likely set by hub upon an "id name" line, though can also be set by info_handler.
eng.search_running = NoSearch; // The search actually being run right now.
eng.search_desired = NoSearch; // The search we want Leela to be running. Often the same object as above.
eng.search_completed = NoSearch; // Whatever object search_running was when the last "bestmove" came.
// -------------------------------------------------------------------------------------------
eng.send = function(msg, force) {
// Importantly, setoption messages are normally held back until the engine is not running.
msg = msg.trim();
if (msg.startsWith("setoption")) {
if (this.search_running.node && !force) {
this.setoption_queue.push(msg);
return;
}
let lower = msg.toLowerCase();
let i1 = lower.indexOf(" name ");
let i2 = lower.indexOf(" value ");
if (i1 !== -1 && i2 !== -1 && i2 > i1) {
let key = lower.slice(i1 + 6, i2).trim(); // Keys are always lowercase.
let val = msg.slice(i2 + 7).trim();
if (key.length > 0) {
this.sent_options[key] = val;
this.send_ack_setoption(key);
}
}
}
// Do this test here so the sent_options / ack stuff happens even when there is no engine
// loaded, this helps our menu check marks to be correct.
if (!this.exe) {
return;
}
// Send the message...
try {
this.exe.stdin.write(msg);
this.exe.stdin.write("\n");
Log("--> " + msg);
this.last_send = msg;
} catch (err) {
Log("(failed) --> " + msg);
if (this.last_send !== null && this.warn_send_fail) {
alert(messages.send_fail);
this.warn_send_fail = false;
}
}
};
eng.send_desired = function() {
if (this.search_running.node) {
throw "send_desired() called but search was running";
}
let node = this.search_desired.node;
if (!node || node.destroyed || node.terminal_reason()) {
this.search_running = NoSearch;
this.search_desired = NoSearch;
return;
}
let root_fen = node.get_root().board.fen(!this.in_960_mode());
let setup = `fen ${root_fen}`;
if (!this.in_960_mode() && setup === "fen rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") {
setup = "startpos"; // May as well send this format if we're not in 960 mode.
}
let moves;
if (!this.in_960_mode()) {
moves = node.history_old_format();
} else {
moves = node.history();
}
if (moves.length === 0) {
this.send(`position ${setup}`);
} else {
this.send(`position ${setup} moves ${moves.join(" ")}`);
}
if (config.log_positions) {
Log(node.board.graphic());
}
let s;
let n = this.search_desired.limit;
if (!n) {
s = "go infinite";
} else if (this.search_desired.limit_by_time) {
s = `go movetime ${n}`;
} else {
s = `go nodes ${n}`;
}
if (config.searchmoves_buttons && this.search_desired.searchmoves.length > 0) {
s += " searchmoves";
for (let move of this.search_desired.searchmoves) {
s += " " + move;
}
}
this.send(s);
this.search_running = this.search_desired;
this.suppress_cycle_info = null;
this.hub.info_handler.engine_cycle++;
this.hub.info_handler.engine_subcycle++;
};
eng.set_search_desired = function(node, limit, limit_by_time, searchmoves) {
if (!this.ever_received_uciok || !this.ever_received_readyok) {
console.log("set_search_desired() aborted - too early");
return;
}
let params = SearchParams(node, limit, limit_by_time, searchmoves);
// It is correct to check these against the *desired* search
// (which may or may not be the one currently running).
if (this.search_desired.node === params.node) {
if (this.search_desired.limit === params.limit) {
if (this.search_desired.limit_by_time === params.limit_by_time) {
if (CompareArrays(this.search_desired.searchmoves, params.searchmoves)) {
return;
}
}
}
}
this.search_desired = params;
// If a search is running, stop it... we will send the new position (if applicable) after receiving bestmove.
// If no search is running, start the new search immediately.
if (this.search_running.node) {
this.send("stop");
if (!this.unresolved_stop_time) {
this.unresolved_stop_time = performance.now();
}
} else {
if (this.search_desired.node) {
this.send_desired();
}
}
};
eng.send_queued_setoptions = function() {
for (let msg of this.setoption_queue) {
this.send(msg, true); // Use the force flag in case we haven't set search_running to its correct value.
}
this.setoption_queue = [];
};
eng.send_ucinewgame = function() { // Engine should be halted before calling this.
if (!this.ever_received_uciok || !this.ever_received_readyok) {
console.log("send_ucinewgame() aborted - too early");
return; // This is OK. When we actually get these, hub will send ucinewgame.
}
this.send("ucinewgame");
};
eng.handle_bestmove_line = function(line) {
this.search_completed = this.search_running;
this.search_running = NoSearch;
this.unresolved_stop_time = null;
// If this.search_desired === this.search_running then the search that just completed is
// the most recent one requested by the hub; we have nothing to replace it with.
//
// Note that, in certain cases (e.g. a halt followed instantly by a resume) search_desired
// and search_running will have identical properties but be different objects; in that case
// it is correct to send the desired object as a new search.
let no_new_search = this.search_desired === this.search_completed || !this.search_desired.node;
let report_bestmove = this.search_desired === this.search_completed && this.search_completed.node;
if (no_new_search) {
this.search_desired = NoSearch;
if (report_bestmove) {
Log("< " + line);
this.send_queued_setoptions(); // After logging the incoming.
this.hub.receive_bestmove(line, this.search_completed.node); // May trigger a new search, so do it last.
} else {
Log("(ignore halted) < " + line);
this.send_queued_setoptions(); // After logging the incoming.
}
} else {
Log("(ignore old) < " + line);
this.send_queued_setoptions(); // After logging the incoming.
this.send_desired();
}
};
eng.handle_info_line = function(line) {
if (line.startsWith("info string ERROR")) { // Stockfish sends these.
Log("< " + line);
this.hub.info_handler.err_receive(line.slice(12));
return;
}
if (!this.search_running.node) {
if (config.log_info_lines) Log("(ignore !node) < " + line);
return;
}
if (this.search_running.node.destroyed) {
if (config.log_info_lines) Log("(ignore destroyed) < " + line);
return;
}
// Stockfish has a nasty habit of sending super short PVs when you stop its search.
// To get around that, we ignore info from SF if it comes during transition.
if (!this.leelaish && this.search_desired.node !== this.search_running.node) {
if (config.log_info_lines) Log("(ignore A/B late) < " + line);
return;
}
// Hub can set a cycle to be suppressed (e.g. for the sake of making "forget all analysis" work).
// This feels a bit sketchy, but will be OK as long as the next "go" is guaranteed to increment the cycle number.
if (this.suppress_cycle_info === this.hub.info_handler.engine_cycle) {
if (config.log_info_lines) Log("(ignore suppressed) < " + line);
return;
}
this.hub.info_handler.receive(this, this.search_running, line); // Responsible for logging lines that get this far.
};
eng.setoption = function(name, value) {
let s = `setoption name ${name} value ${value}`;
this.send(s);
return s; // Just so the caller can pop s up as a message if it wants.
};
eng.pressbutton = function(name) {
let s = `setoption name ${name}`;
this.send(s);
return s; // Just so the caller can pop s up as a message if it wants.
};
eng.send_ack_setoption = function(name) {
let key = name.toLowerCase(); // Keys are always stored in lowercase.
let val = typeof this.sent_options[key] === "string" ? this.sent_options[key] : ""; // Values are strings, if present
let o = {key, val};
ipcRenderer.send("ack_setoption", o);
return o;
};
eng.in_960_mode = function() {
return this.sent_options["uci_chess960"] === "true"; // The string "true" since these values are always strings.
};
eng.known = function(s) {
return this.known_options[s.toLowerCase()] !== undefined;
};
eng.send_ack_engine = function() {
ipcRenderer.send("ack_engine", this.filepath);
};
eng.setup = function(filepath, args) { // Returns true on success, false otherwise.
Log("");
Log(`Launching ${filepath}`);
if (args.length > 0) Log(`Args: ${JSON.stringify(args)}`);
Log("");
try {
if (path.basename(filepath).toLowerCase().includes("lc0")) { // Stupid hack to make Lc0 show all its options.
if (args.includes("--show-hidden") === false) {
args = ["--show-hidden"].concat(args);
}
}
this.exe = child_process.spawn(filepath, args, {cwd: path.dirname(filepath)});
} catch (err) {
console.log(`engine.setup() failed: ${err.toString()}`);
return false;
}
this.filepath = filepath;
this.send_ack_engine(); // After this.filepath is set.
// Main process wants to keep track of what these things are set to (for menu checks).
// These will all ack the value "" to main.js since no value has been set yet...
this.sent_options = Object.create(null); // Blank anything we "sent" up till now.
for (let key of GUI_WANTS_TO_KNOW) {
this.send_ack_setoption(key);
}
this.exe.once("error", (err) => {
alert(err);
});
this.scanner = readline.createInterface({
input: this.exe.stdout,
output: undefined,
terminal: false
});
this.err_scanner = readline.createInterface({
input: this.exe.stderr,
output: undefined,
terminal: false
});
this.err_scanner.on("line", (line) => {
if (this.have_quit) return;
Log(". " + line);
this.hub.err_receive(SafeStringHTML(line));
});
this.scanner.on("line", (line) => {
if (this.have_quit) return;
if (line.startsWith("bestmove")) {
this.handle_bestmove_line(line); // Will do logging, possibly adding a reason for rejection.
} else if (line.startsWith("info")) {
this.handle_info_line(line); // Will do logging, possibly adding a reason for rejection.
} else {
Log("< " + line);
if (line.startsWith("option")) {
let a = line.indexOf(" name ");
let b = line.indexOf(" type ");
if (a !== -1 && b != -1) {
let optname = line.slice(a + 6, b).trim().toLowerCase();
this.known_options[optname] = line.slice(b + 1);
if (optname === "uci_chess960") { // As a special thing, always set UCI_Chess960 where possible.
this.setoption("UCI_Chess960", true); // (Why is this not just done in globals.js? I forget...)
}
}
}
if (line.startsWith("uciok")) {
this.ever_received_uciok = true;
}
if (line.startsWith("readyok")) {
this.ever_received_readyok = true;
}
this.hub.receive_misc(SafeStringHTML(line));
}
});
return true;
};
eng.shutdown = function() { // Note: Don't reuse the engine object.
this.have_quit = true;
this.send("quit");
if (this.exe) {
setTimeout(() => {
this.exe.kill();
}, 2000);
}
};
return eng;
}
================================================
FILE: files/src/renderer/95_hub.js
================================================
"use strict";
function NewHub() {
let hub = Object.create(null);
hub.engine = NewEngine(hub); // Just a dummy object with no exe. Fixed by start.js later.
hub.tree = NewTreeHandler();
hub.grapher = NewGrapher();
hub.looker = NewLooker();
hub.info_handler = NewInfoHandler();
hub.status_handler = NewStatusHandler();
// Various state we have to keep track of...
hub.loaders = []; // The loaders can have shutdown() called on them to stop ASAP.
hub.book = null; // Either a Polyglot buffer, or an array of {key, move, weight}.
hub.pgndata = null; // Object representing the loaded PGN file.
hub.engine_choices = []; // Made by show_fast_engine_chooser() when needed.
hub.fullbox_config_item = null; // Name of config item currently being edited in fullbox.
hub.fullbox_web_link = null; // Web link which can be clicked on in the config editor.
hub.pgn_choices_start = 0; // Where we are in the PGN Chooser screen.
hub.friendly_draws = New2DArray(8, 8, null); // What pieces are drawn in boardfriends. Used to skip redraws.
hub.enemy_draws = New2DArray(8, 8, null); // What pieces are drawn in boardsquares. Used to skip redraws.
hub.dirty_squares = New2DArray(8, 8, null); // What squares have some coloured background.
hub.active_square = null; // Clicked square, shown in blue.
hub.hoverdraw_div = -1; // Which div is hovered; used by draw_infobox().
hub.hoverdraw_depth = 0; // How deep in the hover PV we are.
hub.tick = 0; // How many draw loops we've been through. Used to animate hoverdraw.
hub.position_change_time = performance.now(); // Time of the last position change. Used for cooldown on hoverdraw.
hub.node_to_clean = hub.tree.node; // The next node to be cleaned up (done when exiting it).
hub.leela_lock_node = null; // Non-null only when in "analysis_locked" mode.
hub.looker.add_to_queue(hub.tree.node.board); // Maybe make initial call to API such as ChessDN.cn...
Object.assign(hub, hub_props);
return hub;
}
let hub_props = {
// ---------------------------------------------------------------------------------------------------------------------
// Core methods wrt our main state...
behave: function(reason) { // reason should be "position" or "behaviour"
// Called when position changes.
// Called when behaviour changes.
//
// Each branch should do one of the following:
//
// Call __go() to start a new search
// Call __halt() to ensure the engine isn't running
// Nothing, iff the correct search is already running
if (reason !== "position" && reason !== "behaviour") {
throw "behave(): bad call";
}
switch (config.behaviour) {
case "halt":
this.__halt();
break;
case "analysis_free":
// Note that the 2nd part of the condition is needed because changing behaviour can change what node_limit()
// returns, therefore we might already be running a search for the right node but with the wrong limit.
// THIS IS TRUE THROUGHOUT THIS FUNCTION.
if (this.engine.search_desired.node !== this.tree.node || this.engine.search_desired.limit !== this.node_limit()) {
this.__go(this.tree.node);
}
break;
case "auto_analysis":
case "back_analysis":
if (this.tree.node.terminal_reason()) {
this.continue_auto_analysis(); // This can get a bit recursive, do we care?
} else if (this.engine.search_desired.node !== this.tree.node || this.engine.search_desired.limit !== this.node_limit()) {
this.__go(this.tree.node);
}
break;
case "analysis_locked":
// Moving shouldn't trigger anything, except that re-entering the correct node changes behaviour to halt
// iff the search is completed.
if (reason === "position") {
if (this.tree.node === this.leela_lock_node) {
if (!this.engine.search_desired.node) {
this.set_behaviour_direct("halt");
}
}
} else {
if (this.engine.search_desired.node !== this.leela_lock_node || this.engine.search_desired.limit !== this.node_limit()) {
this.__go(this.leela_lock_node);
}
}
break;
case "self_play":
case "play_white":
case "play_black":
if ((config.behaviour === "self_play") ||
(config.behaviour === "play_white" && this.tree.node.board.active === "w") ||
(config.behaviour === "play_black" && this.tree.node.board.active === "b")) {
if (this.maybe_setup_book_move()) {
this.__halt();
break;
}
if (this.engine.search_desired.node !== this.tree.node || this.engine.search_desired.limit !== this.node_limit()) {
this.__go(this.tree.node);
}
} else { // Play single colour mode, wrong colour.
this.__halt();
}
break;
}
},
position_changed: function(new_game_flag, avoid_confusion) {
// Called right after this.tree.node is changed, meaning we are now drawing a different position.
this.escape();
drag_handler.cancel_drag();
this.hoverdraw_div = -1;
this.position_change_time = performance.now();
fenbox.value = this.tree.node.board.fen(true);
if (new_game_flag) {
this.node_to_clean = null;
this.leela_lock_node = null;
this.set_behaviour("halt"); // Will cause "stop" to be sent.
if (!config.suppress_ucinewgame) {
this.engine.send_ucinewgame(); // Must happen after "stop" is sent.
}
this.send_title();
if (this.engine.ever_received_uciok && !this.engine.in_960_mode() && this.tree.node.board.normalchess === false) {
alert(messages.c960_warning);
}
}
if (this.tree.node.table.already_autopopulated === false) {
this.tree.node.table.autopopulate(this.tree.node);
}
// When entering a position, clear its searchmoves, unless it's the analysis_locked node.
if (this.leela_lock_node !== this.tree.node) {
this.tree.node.searchmoves = [];
}
// Caller can tell us the change would cause user confusion for some modes...
if (avoid_confusion) {
if (["play_white", "play_black", "self_play", "auto_analysis", "back_analysis"].includes(config.behaviour)) {
this.set_behaviour("halt");
}
}
this.maybe_infer_info(); // Before node_exit_cleanup() so that previous ghost info is available when moving forwards.
this.behave("position");
this.draw();
this.node_exit_cleanup(); // This feels like the right time to do this.
this.node_to_clean = this.tree.node;
this.looker.add_to_queue(this.tree.node.board);
},
set_behaviour: function(s) {
if (!this.engine.ever_received_uciok || !this.engine.ever_received_readyok) {
s = "halt";
}
// Don't do anything if behaviour is already correct. But
// "halt" always triggers a behave() call for safety reasons.
if (s === config.behaviour) {
switch (s) {
case "halt":
break; // i.e. do NOT immediately return
case "analysis_locked":
if (this.leela_lock_node !== this.tree.node) {
break; // i.e. do NOT immediately return
}
return;
case "analysis_free":
if (!this.engine.search_desired.node) {
break; // i.e. do NOT immediately return
}
return;
default:
return;
}
}
this.set_behaviour_direct(s);
this.behave("behaviour");
},
set_behaviour_direct: function(s) {
this.leela_lock_node = (s === "analysis_locked") ? this.tree.node : null;
config.behaviour = s;
},
toggle_go: function() {
if (["analysis_free", "self_play", "auto_analysis", "back_analysis"].includes(config.behaviour)) {
this.set_behaviour("halt");
} else if (config.behaviour === "halt") {
this.set_behaviour("analysis_free");
}
},
play_this_colour: function() {
if (this.tree.node.board.active === "w") {
this.set_behaviour("play_white");
} else {
this.set_behaviour("play_black");
}
},
handle_search_params_change: function() {
// If there's already a search desired, we can just let __go() figure out what the new parameters should be.
// If they match what is already desired then set_search_desired() will ignore the call.
if (this.engine.search_desired.node) {
this.__go(this.engine.search_desired.node);
}
// If there's no search desired, changing params probably shouldn't start one. As of 1.8.3, when a search
// completes due to hitting the (normal) node limit, behaviour gets changed back to "halt" in one way or
// another (unless config.allow_stopped_analysis is set).
},
continue_auto_analysis: function() {
let ok;
if (config.behaviour === "auto_analysis") {
ok = this.tree.next();
} else if (config.behaviour === "back_analysis") {
ok = this.tree.prev();
}
if (ok) {
this.position_changed(false, false);
} else {
this.set_behaviour("halt");
}
},
maybe_setup_book_move: function() {
if (!this.book || this.tree.node.terminal_reason()) {
return false;
}
if (typeof config.book_depth === "number" && this.tree.node.depth >= config.book_depth * 2) {
return false;
}
let move;
let objects = BookProbe(KeyFromBoard(this.tree.node.board), this.book);
let total_weight = 0;
if (Array.isArray(objects)) {
for (let o of objects) {
total_weight += o.weight;
}
}
if (total_weight <= 0) {
return false;
}
let rng = RandInt(0, total_weight);
let weight_seen = 0;
for (let o of objects) { // The order doesn't matter at all when you think about it. No need to sort.
weight_seen += o.weight;
if (rng < weight_seen) {
move = o.move;
break;
}
}
if (!move) {
return false;
}
if (this.tree.node.board.illegal(move)) {
return false;
}
let correct_node = this.tree.node;
let correct_behaviour = config.behaviour;
// Use a setTimeout to prevent recursion (since move() will cause a call to behave())
setTimeout(() => {
if (this.tree.node === correct_node && config.behaviour === correct_behaviour) {
this.move(move);
}
}, 0);
return true;
},
maybe_infer_info: function() {
// This function creates "ghost" info in the info table when possible and necessary;
// such info is inferred from ancestral info. It is also deleted upon leaving the node.
//
// The whole thing is a bit sketchy, maybe.
if (config.behaviour === "play_white" || config.behaviour === "play_black") {
return;
}
let node = this.tree.node;
if (node.terminal_reason()) {
return;
}
if (!node.parent) {
return;
}
for (let info of Object.values(node.table.moveinfo)) {
if (info.__touched) {
return;
}
}
// So the current node has no real info.
let moves = [node.move];
let ancestor = null;
let foo = node.parent;
while (foo) {
for (let info of Object.values(foo.table.moveinfo)) {
if (info.__touched) {
ancestor = foo;
break;
}
}
if (!ancestor) {
moves.push(foo.move);
foo = foo.parent;
} else {
break;
}
}
if (!ancestor) {
return;
}
// So we found the closest ancestor with info.
moves.reverse();
let oldinfo = ancestor.table.moveinfo[moves[0]];
if (!oldinfo) {
return;
}
if (Array.isArray(oldinfo.pv) === false || oldinfo.pv.length <= moves.length) {
return;
}
let pv = Array.from(oldinfo.pv);
for (let n = 0; n < moves.length; n++) {
if (pv[n] !== moves[n]) {
return;
}
}
// So, everything matches and we can use the PV...
let nextmove = pv[moves.length];
pv = pv.slice(moves.length);
let new_info = NewInfo(node.board, nextmove);
new_info.set_pv(pv);
new_info.__ghost = true;
new_info.__touched = true;
new_info.subcycle = 1; // Crude hack, makes draw_infobox() make other moves gray.
new_info.q = oldinfo.q;
new_info.cp = oldinfo.cp;
new_info.multipv = 1;
// Flip our evals if the colour changes...
if (oldinfo.board.active !== node.board.active) {
if (typeof new_info.q === "number") {
new_info.q *= -1;
}
if (typeof new_info.cp === "number") {
new_info.cp *= -1;
}
}
node.table.moveinfo[nextmove] = new_info;
},
node_exit_cleanup: function() {
if (!this.node_to_clean || this.node_to_clean.destroyed) {
return;
}
// Remove ghost info; which is only allowed in the node we're currently looking at...
// By remove, I mean, replace it with a neutral info object.
for (let key of Object.keys(this.node_to_clean.table.moveinfo)) {
if (this.node_to_clean.table.moveinfo[key].__ghost) {
this.node_to_clean.table.moveinfo[key] = NewInfo(this.node_to_clean.board, key);
}
}
},
// ---------------------------------------------------------------------------------------------------------------------
// Spin, our main loop...
spin: function() {
this.tick++;
this.draw();
this.purge_finished_loaders();
this.maybe_save_window_size();
setTimeout(this.spin.bind(this), config.update_delay);
},
purge_finished_loaders: function() {
this.loaders = this.loaders.filter(o => o.callback);
},
maybe_save_window_size: function() {
if (this.window_resize_time && performance.now() - this.window_resize_time > 1000) {
this.window_resize_time = null;
this.save_window_size();
}
},
// ---------------------------------------------------------------------------------------------------------------------
// Drawing properties...
draw: function() {
// We do the :hover reaction first. This way, we are detecting hover based on the previous cycle's state.
// This should prevent the sort of flicker that can occur if we try to detect hover based on changes we
// just made (i.e. if we drew then detected hover instantly).
let did_hoverdraw = this.hoverdraw();
if (did_hoverdraw) {
canvas.style.outline = "2px dashed #b4b4b4";
} else {
this.hoverdraw_div = -1;
boardfriends.style.display = "block";
canvas.style.outline = "none";
this.draw_move_and_active_squares(this.tree.node.move, this.active_square);
this.draw_enemies_in_table(this.tree.node.board);
this.draw_canvas_arrows();
this.draw_friendlies_in_table(this.tree.node.board);
}
this.draw_statusbox();
this.draw_infobox();
this.grapher.draw(this.tree.node);
},
draw_friendlies_in_table: function(board) {
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
let piece_to_draw = "";
if (board.colour(Point(x, y)) === board.active) {
piece_to_draw = board.state[x][y];
}
if (piece_to_draw === this.friendly_draws[x][y]) {
continue;
}
// So if we get to here, we need to draw...
this.friendly_draws[x][y] = piece_to_draw;
let s = S(x, y);
let td = document.getElementById("overlay_" + s);
if (piece_to_draw === "") {
td.style["background-image"] = "none";
} else {
td.style["background-image"] = images[piece_to_draw].string_for_bg_style;
}
}
}
},
draw_enemies_in_table: function(board) {
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
let piece_to_draw = "";
if (board.colour(Point(x, y)) === OppositeColour(board.active)) {
piece_to_draw = board.state[x][y];
}
if (piece_to_draw === this.enemy_draws[x][y]) {
continue;
}
// So if we get to here, we need to draw...
this.enemy_draws[x][y] = piece_to_draw;
let s = S(x, y);
let td = document.getElementById("underlay_" + s);
if (piece_to_draw === "") {
td.style["background-image"] = "none";
} else {
td.style["background-image"] = images[piece_to_draw].string_for_bg_style;
}
}
}
},
draw_move_and_active_squares: function(move, active_square) {
// These constants are stupidly used in set_active_square() also.
const EMPTY = 0;
const HIGHLIGHT = 1;
const ACTIVE = 2;
if (!this.dmaas_scratch) {
this.dmaas_scratch = New2DArray(8, 8, null);
}
// First, set each element of the array to indicate what state we want
// its background-color to be in.
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
this.dmaas_scratch[x][y] = EMPTY;
}
}
let move_points = [];
if (typeof move === "string") {
let source = Point(move.slice(0, 2));
let dest = Point(move.slice(2, 4));
if (source && dest) {
move_points = PointsBetween(source, dest);
}
}
for (let p of move_points) {
this.dmaas_scratch[p.x][p.y] = HIGHLIGHT;
}
if (active_square) {
this.dmaas_scratch[active_square.x][active_square.y] = ACTIVE;
}
// Now the dmaas_scratch array has what we actually want.
// We check whether each square is already so, and change it otherwise.
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
switch (this.dmaas_scratch[x][y]) {
case EMPTY:
if (this.dirty_squares[x][y] !== EMPTY) {
let s = S(x, y);
let td = document.getElementById("underlay_" + s);
td.style["background-color"] = "transparent";
this.dirty_squares[x][y] = EMPTY;
}
break;
case HIGHLIGHT:
if (this.dirty_squares[x][y] !== HIGHLIGHT) {
let s = S(x, y);
let td = document.getElementById("underlay_" + s);
td.style["background-color"] = config.move_squares_with_alpha;
this.dirty_squares[x][y] = HIGHLIGHT;
}
break;
case ACTIVE:
if (this.dirty_squares[x][y] !== ACTIVE) {
let s = S(x, y);
let td = document.getElementById("underlay_" + s);
td.style["background-color"] = config.active_square;
this.dirty_squares[x][y] = ACTIVE;
}
break;
}
}
}
},
hoverdraw: function() {
if (!config.hover_draw || this.info_handler.clickers_are_valid_for_node(this.tree.node) === false) {
return false;
}
if (performance.now() - this.position_change_time < 1000) {
return false;
}
let overlist = document.querySelectorAll(":hover");
// Find what div we are over by looking for infoline_n
let div = null;
let div_index = null;
for (let item of overlist) {
if (typeof item.id === "string" && item.id.startsWith("infoline_")) {
div = item;
div_index = parseInt(item.id.slice("infoline_".length), 10);
break;
}
}
if (!div || typeof div_index !== "number" || Number.isNaN(div_index)) {
return false;
}
// Find what infobox clicker we are over by looking for infobox_n
let click_n = null;
for (let item of overlist) {
if (typeof item.id === "string" && item.id.startsWith("infobox_")) {
click_n = parseInt(item.id.slice("infobox_".length), 10);
break;
}
}
if (typeof click_n !== "number" || Number.isNaN(click_n)) {
// We failed to get a click_n value. But if we are in Animate or Final Position mode,
// it should still work even if the user isn't hovering over a move exactly; we can
// just pass any valid click_n from the line... this is a pretty dumb hack.
if (config.hover_method !== 0 && config.hover_method !== 2) {
return false;
}
for (let item of div.childNodes) {
if (typeof item.id === "string" && item.id.startsWith("infobox_")) {
click_n = parseInt(item.id.slice("infobox_".length), 10);
break;
}
}
if (typeof click_n !== "number" || Number.isNaN(click_n)) {
return false;
}
}
//
if (config.hover_method === 0) {
return this.hoverdraw_animate(div_index, click_n); // Sets this.hoverdraw_div
} else if (config.hover_method === 1) {
return this.hoverdraw_single(div_index, click_n); // Sets this.hoverdraw_div
} else if (config.hover_method === 2) {
return this.hoverdraw_final(div_index, click_n); // Sets this.hoverdraw_div
} else {
return false; // Caller must set this.hoverdraw_div to -1
}
},
hoverdraw_animate: function(div_index, click_n) {
// If the user is hovering over an unexpected div index in the infobox, reset depth...
if (div_index !== this.hoverdraw_div) {
this.hoverdraw_div = div_index;
this.hoverdraw_depth = 0;
}
// Sometimes increase depth...
if (this.tick % config.animate_delay_multiplier === 0) {
this.hoverdraw_depth++;
}
let moves = this.info_handler.moves_from_click_n(click_n, this.hoverdraw_depth);
if (Array.isArray(moves) === false || moves.length === 0) {
return false;
}
return this.draw_fantasy_from_moves(moves);
},
hoverdraw_single: function(div_index, click_n) {
this.hoverdraw_div = div_index;
let moves = this.info_handler.moves_from_click_n(click_n);
if (Array.isArray(moves) === false || moves.length === 0) {
return false;
}
return this.draw_fantasy_from_moves(moves);
},
hoverdraw_final: function(div_index, click_n) {
this.hoverdraw_div = div_index;
let moves = this.info_handler.moves_from_click_n(click_n, 999);
if (Array.isArray(moves) === false || moves.length === 0) {
return false;
}
return this.draw_fantasy_from_moves(moves);
},
draw_fantasy_from_moves: function(moves) {
// We don't assume moves is an array of legal moves, or even an array.
// This is probably paranoid at this point but meh.
if (Array.isArray(moves) === false) {
return false;
}
let board = this.tree.node.board;
for (let move of moves) {
let illegal_reason = board.illegal(move);
if (illegal_reason) {
return false;
}
board = board.move(move);
}
let move = moves[moves.length - 1]; // Possibly undefined...
this.draw_fantasy(board, move);
return true;
},
draw_fantasy: function(board, move) {
this.draw_move_and_active_squares(move, null);
this.draw_enemies_in_table(board);
boardctx.clearRect(0, 0, canvas.width, canvas.height); // Clearing the canvas arrows.
this.draw_friendlies_in_table(board);
},
draw_canvas_arrows: function() {
boardctx.clearRect(0, 0, canvas.width, canvas.height);
if (config.book_explorer) {
this.draw_explorer_arrows();
} else if (config.lichess_explorer) {
this.draw_lichess_arrows();
} else {
let arrow_spotlight_square = config.click_spotlight ? this.active_square : null;
let next_move = (config.next_move_arrow && this.tree.node.children.length > 0) ? this.tree.node.children[0].move : null;
this.info_handler.draw_arrows(this.tree.node, arrow_spotlight_square, next_move);
}
},
draw_explorer_arrows: function() {
// This is all pretty isolated from everything else. Keep it that way.
if (!this.book) {
this.explorer_objects_cache = null;
this.explorer_cache_node_id = null;
this.info_handler.draw_explorer_arrows(this.tree.node, [], null); // Needs to happen, to update the one_click_moves.
return;
}
if (!this.explorer_objects_cache || this.explorer_cache_node_id !== this.tree.node.id) {
let objects = BookProbe(KeyFromBoard(this.tree.node.board), this.book);
let total_weight = 0;
if (Array.isArray(objects)) {
for (let o of objects) {
total_weight += o.weight;
}
}
if (total_weight <= 0) {
total_weight = 1; // Avoid div by zero.
}
let tmp = {};
for (let o of objects) {
if (!this.tree.node.board.illegal(o.move)) {
if (tmp[o.move] === undefined) {
tmp[o.move] = {move: o.move, weight: o.weight / total_weight};
}
}
}
this.explorer_cache_node_id = this.tree.node.id;
this.explorer_objects_cache = Object.values(tmp);
this.explorer_objects_cache.sort((a, b) => b.weight - a.weight);
}
let arrow_spotlight_square = config.click_spotlight ? this.active_square : null;
this.info_handler.draw_explorer_arrows(this.tree.node, this.explorer_objects_cache, arrow_spotlight_square);
},
draw_lichess_arrows: function() {
// Modified version of the above.
let ok = true;
if (config.looker_api !== "lichess_masters" && config.looker_api !== "lichess_plebs") {
ok = false;
}
let entry = this.looker.lookup(config.looker_api, this.tree.node.board);
if (!entry) {
ok = false;
}
if (!ok) {
this.explorer_objects_cache = null;
this.explorer_cache_node_id = null;
this.info_handler.draw_explorer_arrows(this.tree.node, [], null); // Needs to happen, to update the one_click_moves.
return;
}
if (!this.explorer_objects_cache || this.explorer_cache_node_id !== this.tree.node.id) {
let total_weight = 0;
for (let o of Object.values(entry.moves)) {
total_weight += o.total;
}
if (total_weight <= 0) {
total_weight = 1; // Avoid div by zero.
}
let tmp = {};
for (let move of Object.keys(entry.moves)) {
if (!this.tree.node.board.illegal(move)) {
if (tmp[move] === undefined) {
tmp[move] = {move: move, weight: entry.moves[move].total / total_weight};
}
}
}
this.explorer_cache_node_id = this.tree.node.id;
this.explorer_objects_cache = Object.values(tmp);
this.explorer_objects_cache.sort((a, b) => b.weight - a.weight);
}
let arrow_spotlight_square = config.click_spotlight ? this.active_square : null;
this.info_handler.draw_explorer_arrows(this.tree.node, this.explorer_objects_cache, arrow_spotlight_square);
},
draw_statusbox: function() {
let analysing_other = null;
if (config.behaviour === "analysis_locked" && this.leela_lock_node && this.leela_lock_node !== this.tree.node) {
if (!this.leela_lock_node.parent) {
analysing_other = "root";
} else {
analysing_other = "position after " + this.leela_lock_node.token(false, true);
}
}
let loading_message = null;
for (let loader of this.loaders) {
if (loader.callback) { // By our rules, can only exist if the load is still pending...
if (performance.now() - loader.starttime > 100) {
loading_message = loader.msg;
break;
}
}
}
this.status_handler.draw_statusbox(
this.tree.node,
this.engine,
analysing_other,
loading_message,
this.book ? true : false
);
},
draw_infobox: function() {
this.info_handler.draw_infobox(
this.tree.node,
this.mouse_point(),
this.active_square,
this.tree.node.board.active,
this.hoverdraw_div,
config.behaviour === "halt" || config.never_suppress_searchmoves,
config.looker_api ? this.looker.lookup(config.looker_api, this.tree.node.board) : null);
},
// ---------------------------------------------------------------------------------------------------------------------
// Fundamental engine methods... not to be called directly, except by behave() and handle_search_params_change()...
__halt: function() {
this.engine.set_search_desired(null);
},
__go: function(node) {
this.hide_fullbox();
if (!node || node.destroyed || node.terminal_reason()) {
this.engine.set_search_desired(null);
return;
}
this.engine.set_search_desired(node, this.node_limit(), engineconfig[this.engine.filepath].limit_by_time, node.searchmoves);
},
// ---------------------------------------------------------------------------------------------------------------------
// Info receivers...
receive_bestmove: function(s, relevant_node) {
let ok; // Could be used by 2 different parts of the switch (but not at time of writing...)
switch (config.behaviour) {
case "self_play":
case "play_white":
case "play_black":
if (relevant_node !== this.tree.node) {
LogBoth(`(ignored bestmove, relevant_node !== hub.tree.node, config.behaviour was "${config.behaviour}")`);
this.set_behaviour("halt");
break;
}
let tokens = s.split(" ").filter(z => z !== "");
ok = this.move(tokens[1]);
if (!ok) {
LogBoth(`BAD BESTMOVE (${tokens[1]}) IN POSITION ${this.tree.node.board.fen(true)}`);
this.set_special_message(`WARNING! Bad bestmove (${tokens[1]}) received!`, "yellow", 10000);
} else {
if (this.tree.node.terminal_reason()) {
this.set_behaviour("halt");
}
}
break;
case "auto_analysis":
case "back_analysis":
if (relevant_node !== this.tree.node) {
LogBoth(`(ignored bestmove, relevant_node !== hub.tree.node, config.behaviour was "${config.behaviour}")`);
this.set_behaviour("halt");
} else {
this.continue_auto_analysis();
}
break;
case "analysis_free": // We hit the node limit.
if (!config.allow_stopped_analysis) {
this.set_behaviour("halt");
}
break;
case "analysis_locked":
// We hit the node limit. If the node we're looking at isn't the locked node, don't
// change behaviour. (It will get changed when we enter the locked node.)
if (this.tree.node === this.leela_lock_node) {
this.set_behaviour("halt");
}
break;
}
},
receive_misc: function(s) {
if (s.startsWith("id name")) {
// Note that we do need to set the leelaish flag on the engine here (rather than relying on the
// autodetection in info.js) so that correct options can be sent.
this.engine.leelaish = false;
for (let name of config.leelaish_names) {
if (s.includes(name)) {
this.engine.leelaish = true;
break;
}
}
// Our defaults in engineconfig_io.newentry() are appropriate for Leelaish engines.
// But if this is the first time we see an A/B engine, we must adjust them...
if (!this.engine.leelaish && !engineconfig[this.engine.filepath].options["MultiPV"]) {
// This likely indicates the engine is new to the config.
engineconfig[this.engine.filepath].options["MultiPV"] = 3; // Will get ack'd when engine_send_all_options() happens
engineconfig[this.engine.filepath].search_nodes_special = 10000000;
this.send_ack_node_limit(true);
}
// Pass unknown engines to the error handler to be displayed...
if (!s.includes("Lc0") && !s.includes("Ceres") && !s.includes("Stockfish")) {
this.info_handler.err_receive(s.slice("id name".length).trim());
}
return;
}
if (s.startsWith("uciok")) {
// Until we receive uciok and readyok, set_behaviour() does nothing and set_search_desired() ignores calls, so "go" cannot have been sent.
this.engine_send_all_options();
this.engine.send("isready");
return;
}
if (s.startsWith("readyok")) {
// Until we receive uciok and readyok, set_behaviour() does nothing and set_search_desired() ignores calls, so "go" cannot have been sent.
this.set_behaviour("halt"); // Likely redundant (should be "halt" anyway), but ensures the hub is in a sane state.
this.engine.send_ucinewgame(); // Relies on the engine not running.
return;
}
// Misc messages. Treat ones that aren't valid UCI as errors to be passed along...
if (!s.startsWith("id") &&
!s.startsWith("option") &&
!s.startsWith("bestmove") && // These messages shouldn't reach this function
!s.startsWith("info") // These messages shouldn't reach this function
) {
this.info_handler.err_receive(s);
}
},
err_receive: function(s) {
// Some highlights... this is obviously super-fragile based on the precise strings Leela sends.
if (s.startsWith("Found configuration file: ")) {
this.info_handler.err_receive(HighlightString(s, "Found configuration file: ", "blue"));
return;
}
if (s.startsWith("Loading Syzygy tablebases from ")) {
this.info_handler.err_receive(HighlightString(s, "Loading Syzygy tablebases from ", "blue"));
return;
}
if (s.startsWith("Loading weights file from: ")) {
this.info_handler.err_receive(HighlightString(s, "Loading weights file from: ", "blue"));
return;
}
if (s.startsWith("Found pb network file: ")) {
this.info_handler.err_receive(HighlightString(s, "Found pb network file: ", "blue"));
return;
}
this.info_handler.err_receive(s);
},
// ---------------------------------------------------------------------------------------------------------------------
// Node limits...
node_limit: function() {
// Given the current state of the config, what is the node limit?
// Note that this value is used as a time limit instead, if engineconfig[this.engine.filepath].limit_by_time is set.
let cfg_value;
switch (config.behaviour) {
case "play_white":
case "play_black":
case "self_play":
case "auto_analysis":
case "back_analysis":
cfg_value = engineconfig[this.engine.filepath].search_nodes_special;
break;
default:
cfg_value = engineconfig[this.engine.filepath].search_nodes;
break;
}
// Should match the system in engine.js.
if (typeof cfg_value === "number" && cfg_value >= 1) {
return cfg_value;
} else {
return null;
}
},
adjust_node_limit: function(direction, special_flag) {
let cfg_value = special_flag ? engineconfig[this.engine.filepath].search_nodes_special : engineconfig[this.engine.filepath].search_nodes;
if (direction > 0) {
if (typeof cfg_value !== "number" || cfg_value <= 0) { // Already unlimited
this.set_node_limit_generic(null, special_flag);
return;
}
for (let i = 0; i < limit_options.length; i++) {
if (limit_options[i] > cfg_value) {
this.set_node_limit_generic(limit_options[i], special_flag);
return;
}
}
this.set_node_limit_generic(null, special_flag);
} else {
if (typeof cfg_value !== "number" || cfg_value <= 0) { // Unlimited; reduce to highest finite option
this.set_node_limit_generic(limit_options[limit_options.length - 1], special_flag);
return;
}
for (let i = limit_options.length - 1; i >= 0; i--) {
if (limit_options[i] < cfg_value) {
this.set_node_limit_generic(limit_options[i], special_flag);
return;
}
}
this.set_node_limit_generic(1, special_flag);
}
},
set_node_limit: function(val) {
this.set_node_limit_generic(val, false);
},
set_node_limit_special: function(val) {
this.set_node_limit_generic(val, true);
},
set_node_limit_generic: function(val, special_flag) {
if (typeof val !== "number" || val <= 0) {
val = null;
}
let msg_start;
let by_time = engineconfig[this.engine.filepath].limit_by_time;
if (by_time) {
msg_start = special_flag ? "Special time limit" : "Time limit";
} else {
msg_start = special_flag ? "Special node limit" : "Node limit";
}
if (val) {
this.set_special_message(`${msg_start} now ${CommaNum(val)} ${by_time ? "ms" : ""}`, "blue");
} else {
this.set_special_message(`${msg_start} removed!`, "blue");
}
if (special_flag) {
engineconfig[this.engine.filepath].search_nodes_special = val;
} else {
engineconfig[this.engine.filepath].search_nodes = val;
}
this.send_ack_node_limit(special_flag);
this.handle_search_params_change();
},
send_ack_node_limit: function(special_flag) {
let ack_type = special_flag ? "ack_special_node_limit" : "ack_node_limit";
let val;
if (special_flag) {
val = engineconfig[this.engine.filepath].search_nodes_special;
} else {
val = engineconfig[this.engine.filepath].search_nodes;
}
if (val) {
ipcRenderer.send(ack_type, CommaNum(val));
} else {
ipcRenderer.send(ack_type, "Unlimited");
}
},
toggle_limit_by_time: function() {
engineconfig[this.engine.filepath].limit_by_time = !engineconfig[this.engine.filepath].limit_by_time;
this.send_ack_limit_by_time();
this.handle_search_params_change();
},
send_ack_limit_by_time: function() {
ipcRenderer.send("ack_limit_by_time", engineconfig[this.engine.filepath].limit_by_time);
},
// ---------------------------------------------------------------------------------------------------------------------
// Engine-related acks...
send_ack_engine: function() {
this.engine.send_ack_engine();
},
send_ack_setoption: function(name) {
this.engine.send_ack_setoption(name);
},
// ---------------------------------------------------------------------------------------------------------------------
// Misc engine methods...
soft_engine_reset: function() {
this.set_behaviour("halt"); // Will cause "stop" to be sent.
this.engine.send_ucinewgame(); // Must happen after "stop" is sent.
},
forget_analysis: function() {
CleanTree(this.tree.root);
this.tree.node.table.autopopulate(this.tree.node);
this.set_behaviour("halt"); // Will cause "stop" to be sent.
this.engine.send_ucinewgame(); // Must happen after "stop" is sent.
this.engine.suppress_cycle_info = this.info_handler.engine_cycle; // Ignore further info updates from this cycle.
},
// ---------------------------------------------------------------------------------------------------------------------
// UCI options...
set_uci_option: function(name, val, save_to_cfg = false, blue_text = true) {
// Note that all early returns from this function need to send an ack
// of the prevailing value to fix checkmarks in the main process.
if (!this.engine.ever_received_uciok) { // Correct leelaish flag not yet known.
alert(messages.too_soon_to_set_options);
this.engine.send_ack_setoption(name);
return;
}
if (this.engine.leelaish && name.toLowerCase() === "multipv") {
this.set_special_message("MultiPV should be 500 for this engine", "blue");
this.engine.send_ack_setoption(name);
return;
}
if (!this.engine.known(name)) {
this.set_special_message(`${name} not known by this engine`, "blue");
this.engine.send_ack_setoption(name);
return;
}
if (save_to_cfg) {
if (val === null || val === undefined) {
delete engineconfig[this.engine.filepath].options[name];
} else {
engineconfig[this.engine.filepath].options[name] = val;
}
}
if (val === null || val === undefined) {
val = "";
}
this.set_behaviour("halt");
let sent = this.engine.setoption(name, val); // Will ack the new value.
if (blue_text) {
this.set_special_message(sent, "blue");
}
},
set_uci_option_permanent: function(name, val) {
this.set_uci_option(name, val, true);
},
set_uci_option_permanent_and_cleartree: function(name, val) {
this.set_uci_option(name, val, true);
if (this.engine.leelaish) {
this.set_uci_option("ClearTree", true, false, false);
}
},
disable_syzygy: function() {
delete engineconfig[this.engine.filepath].options["SyzygyPath"];
this.restart_engine(); // Causes the correct ack to be sent.
},
auto_weights: function() {
delete engineconfig[this.engine.filepath].options["EvalFile"];
delete engineconfig[this.engine.filepath].options["WeightsFile"];
this.restart_engine(); // Causes the correct acks to be sent.
},
// ---------------------------------------------------------------------------------------------------------------------
// Engine startup...
reload_engineconfig: function() {
[load_err2, engineconfig] = engineconfig_io.load();
if (load_err2) {
alert(load_err2);
}
this.restart_engine();
},
switch_engine: function(filename) {
this.set_behaviour("halt");
if (this.engine_start(filename)) {
config.path = filename;
} else {
alert("Failed to start this engine.");
this.engine.send_ack_engine();
}
},
restart_engine: function() {
this.engine.warn_send_fail = false; // Don't want "send failed" warnings from old engine any more.
this.set_behaviour("halt");
if (this.engine_start(config.path)) {
// pass
} else {
alert("Failed to restart the engine.");
this.engine.send_ack_engine();
}
},
engine_start: function(filepath, blue_fail) {
if (!filepath || typeof filepath !== "string" || fs.existsSync(filepath) === false) {
if (blue_fail && !load_err1 && !load_err2) {
this.err_receive(`${messages.engine_not_present}`);
this.err_receive("");
}
return false;
}
let args = engineconfig[filepath] ? engineconfig[filepath].args : [];
let new_engine = NewEngine(this);
let success = new_engine.setup(filepath, args, this);
if (success === false) {
if (blue_fail && !load_err1 && !load_err2) {
this.err_receive(`${messages.engine_failed_to_start}`);
this.err_receive("");
}
return false;
}
this.engine.shutdown();
this.engine = new_engine; // Don't reuse engine objects, not even the dummy object. There are sync issues due to fake "go"s.
if (!engineconfig[this.engine.filepath]) {
engineconfig[this.engine.filepath] = engineconfig_io.newentry();
console.log(`Creating new entry in engineconfig for ${filepath}`);
}
this.engine.send("uci");
this.send_ack_node_limit(false); // Ack the node limits that are set in engineconfig[this.engine.filepath]
this.send_ack_node_limit(true);
this.send_ack_limit_by_time(); // Also ack the limit_by_time boolean for that menu item.
this.info_handler.reset_engine_info();
this.info_handler.must_draw_infobox(); // To display the new stderr log that appears.
return true;
},
engine_send_all_options: function() { // The engine should never have been given a "go" before this.
// Options that are sent regardless of whether the engine seems to know about them...
let forced_engine_options = this.engine.leelaish ? forced_lc0_options : forced_ab_options;
for (let [key, value] of Object.entries(forced_engine_options)) {
this.engine.setoption(key, value);
}
// Standard options... only sent if the engine has said it knows them...
let standard_engine_options = this.engine.leelaish ? standard_lc0_options : standard_ab_options;
for (let [key, value] of Object.entries(standard_engine_options)) {
if (this.engine.known(key)) {
this.engine.setoption(key, value);
}
}
// Now send user-selected options. Thus, the user can override anything above.
let options = engineconfig[this.engine.filepath].options;
let keys = Object.keys(options);
keys.sort((a, b) => { // "It is recommended to set Hash after setting Threads."
if (a.toLowerCase() === "hash" && b.toLowerCase() !== "hash") return 1;
if (a.toLowerCase() !== "hash" && b.toLowerCase() === "hash") return -1;
return 0;
});
for (let key of keys) {
this.engine.setoption(key, options[key]);
}
},
// ---------------------------------------------------------------------------------------------------------------------
// Tree manipulation methods...
move: function(s) { // It is safe to call this with illegal moves.
if (typeof s !== "string") {
console.log(`hub.move(${s}) - bad argument`);
return false;
}
let board = this.tree.node.board;
let source = Point(s.slice(0, 2));
if (!source) {
console.log(`hub.move(${s}) - invalid source`);
return false;
}
// First deal with old-school castling in Standard Chess...
s = board.c960_castling_converter(s);
// If a promotion character is required and not present, show the promotion chooser and return
// without committing to anything.
if (s.length === 4) {
if ((board.piece(source) === "P" && source.y === 1) || (board.piece(source) === "p" && source.y === 6)) {
let illegal_reason = board.illegal(s + "q");
if (illegal_reason) {
console.log(`hub.move(${s}) - ${illegal_reason}`);
} else {
this.show_promotiontable(s);
}
return false;
}
}
// The promised legality check...
let illegal_reason = board.illegal(s);
if (illegal_reason) {
console.log(`hub.move(${s}) - ${illegal_reason}`);
return false;
}
this.tree.make_move(s);
this.position_changed();
return true;
},
random_move: function() {
let legals = this.tree.node.board.movegen();
if (legals.length > 0) {
this.move(RandChoice(legals));
}
},
play_info_index: function(n) {
let line_starts = this.info_handler.info_clickers.filter(o => o.is_start);
if (n < line_starts.length) {
let move = line_starts[n].move;
let table_move = this.tree.node.table.moveinfo[move];
if (table_move && table_move.__touched) { // Allow this to happen if the move is touched
this.move(move);
} else if (config.looker_api) { // Allow this to happen if the move is in the selected API database
let db_entry = this.looker.lookup(config.looker_api, this.tree.node.board);
if (db_entry && db_entry.moves[move]) {
this.move(move);
}
}
}
},
// Note that the various tree.methods() return whether or not the current node changed.
return_to_lock: function() {
if (config.behaviour === "analysis_locked") {
if (this.tree.set_node(this.leela_lock_node)) { // Fool-proof against null / destroyed.
this.position_changed(false, true);
}
}
},
prev: function() {
if (this.tree.prev()) {
this.position_changed(false, true);
}
},
next: function() {
if (this.tree.next()) {
this.position_changed(false, true);
}
},
goto_root: function() {
if (this.tree.goto_root()) {
this.position_changed(false, true);
}
},
goto_end: function() {
if (this.tree.goto_end()) {
this.position_changed(false, true);
}
},
previous_sibling: function() {
if (this.tree.previous_sibling()) {
this.position_changed(false, true);
}
},
next_sibling: function() {
if (this.tree.next_sibling()) {
this.position_changed(false, true);
}
},
return_to_main_line: function() {
if (this.tree.return_to_main_line()) {
this.position_changed(false, true);
}
},
delete_node: function() {
if (this.tree.delete_node()) {
this.position_changed(false, true);
}
},
promote_to_main_line: function() {
this.tree.promote_to_main_line();
},
promote: function() {
this.tree.promote();
},
delete_other_lines: function() {
this.tree.delete_other_lines();
},
delete_children: function() {
this.tree.delete_children();
},
delete_siblings: function() {
this.tree.delete_siblings();
},
// ---------------------------------------------------------------------------------------------------------------------
new_game: function() {
this.load_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
},
new_960: function(n) {
if (n === undefined) {
n = RandInt(0, 960);
}
this.load_fen(c960_fen(n), true);
},
// ---------------------------------------------------------------------------------------------------------------------
pgn_to_clipboard: function() {
PGNToClipboard(this.tree.node);
},
save: function(filename) {
SavePGN(filename, this.tree.node);
},
// ---------------------------------------------------------------------------------------------------------------------
// Loading PGN...
open: function(filename) {
if (filename === __dirname || filename === ".") { // Can happen when extra args are passed to main process. Silently return.
return;
}
if (fs.existsSync(filename) === false) { // Can happen when extra args are passed to main process. Silently return.
return;
}
if (!config.ignore_filesize_limits && FileExceedsGigabyte(filename, 2)) {
alert(messages.file_too_big);
return;
}
for (let loader of this.loaders) {
if (loader.type === "pgn") {
loader.shutdown();
}
}
console.log(`Loading PGN: ${filename}`);
let loader = NewFastPGNLoader(filename, (err, pgndata) => {
if (!err) {
pgndata.source = path.basename(filename);
this.handle_loaded_pgndata(pgndata);
} else {
console.log(err);
}
});
this.loaders.push(loader);
},
handle_loaded_pgndata: function(pgndata) {
if (!pgndata || pgndata.count() === 0) {
alert("No data found.");
return;
}
if (pgndata.count() === 1) {
let success = this.load_pgn_object(pgndata.getrecord(0));
if (success) {
this.pgndata = pgndata;
this.pgn_choices_start = 0;
}
} else {
this.pgndata = pgndata;
this.pgn_choices_start = 0;
this.show_pgn_chooser();
}
},
load_pgn_object: function(o) { // Returns true or false - whether this actually succeeded.
let root_node;
try {
root_node = LoadPGNRecord(o);
} catch (err) {
alert(err);
return false;
}
this.tree.replace_tree(root_node);
this.position_changed(true, true);
return true;
},
// ---------------------------------------------------------------------------------------------------------------------
// Books...
unload_book: function() {
this.book = null;
for (let loader of this.loaders) {
if (loader.type === "book") {
loader.shutdown();
}
}
this.send_ack_book();
},
load_polyglot_book: function(filename) {
if (!config.ignore_filesize_limits && FileExceedsGigabyte(filename, 2)) {
alert(messages.file_too_big);
this.send_ack_book();
return;
}
this.book = null;
this.send_ack_book();
for (let loader of this.loaders) {
if (loader.type === "book") {
loader.shutdown();
}
}
console.log(`Loading Polyglot book: ${filename}`);
let loader = NewPolyglotBookLoader(filename, (err, data) => {
if (!err) {
if (BookSortedTest(data)) {
this.book = data;
this.explorer_objects_cache = null;
this.send_ack_book();
this.set_special_message(`Finished loading book (moves: ${Math.floor(data.length / 16)})`, "green");
} else {
alert(messages.bad_bin_book);
}
} else {
console.log(err);
}
});
this.loaders.push(loader);
},
load_pgn_book: function(filename) {
if (!config.ignore_filesize_limits && FileExceedsGigabyte(filename, 0.02)) {
alert(messages.pgn_book_too_big);
this.send_ack_book();
return;
}
this.book = null;
this.send_ack_book();
for (let loader of this.loaders) {
if (loader.type === "book") {
loader.shutdown();
}
}
console.log(`Loading PGN book: ${filename}`);
let loader = NewPGNBookLoader(filename, (err, data) => {
if (!err) {
this.book = data;
this.explorer_objects_cache = null;
this.send_ack_book();
this.set_special_message(`Finished loading book (moves: ${data.length})`, "green");
} else {
console.log(err);
}
});
this.loaders.push(loader);
},
send_ack_book: function() {
let msg = false;
if (this.book) {
msg = this.book instanceof Buffer ? "polyglot" : "pgn";
}
ipcRenderer.send("ack_book", msg);
},
// ---------------------------------------------------------------------------------------------------------------------
// Loading from clipboard or fenbox...
load_fen_or_pgn_from_string: function(s) {
if (typeof s !== "string") return;
s = s.trim();
try {
LoadFEN(s); // Used as a test. Throws on any error.
this.load_fen(s);
} catch (err) {
this.load_pgn_from_string(s);
}
},
load_pgn_from_string: function(s) {
if (typeof s !== "string") {
return;
}
let buf = Buffer.from(s);
console.log(`Loading PGN from string...`);
for (let loader of this.loaders) {
if (loader.type === "pgn") {
loader.shutdown();
}
}
let loader = NewFastPGNLoader(buf, (err, pgndata) => {
if (!err) {
pgndata.source = "From clipboard";
this.handle_loaded_pgndata(pgndata);
} else {
console.log(err);
}
});
this.loaders.push(loader);
},
load_fen: function(s, abnormal) {
let board;
try {
board = LoadFEN(s);
// If the FEN loader thought it looked like normal chess, we must
// override it if the caller passed the abnormal flag. Note that
// it is never permissible to go in the opposite direction... if
// the loader thought it was abnormal, we never say it's normal.
if (abnormal) {
board.normalchess = false;
}
} catch (err) {
alert(err);
return;
}
this.tree.replace_tree(NewRoot(board));
this.position_changed(true, true);
},
load_from_fenbox: function(s) {
s = s.trim();
if (s === this.tree.node.board.fen(true)) {
return;
}
let abnormal = false;
// Allow loading a Chess 960 position by giving its ID:
if (s.length <= 3) {
let n = parseInt(s, 10);
if (Number.isNaN(n) === false && n < 960) {
s = c960_fen(n);
abnormal = true;
}
}
// Allow loading a fruity start position by giving the pieces:
if (s.length === 8) {
let ok = true;
for (let c of s) {
if (["K", "k", "Q", "q", "R", "r", "B", "b", "N", "n"].includes(c) === false) {
ok = false;
break;
}
}
if (ok) {
s = `${s.toLowerCase()}/pppppppp/8/8/8/8/PPPPPPPP/${s.toUpperCase()} w KQkq - 0 1`;
abnormal = true;
}
}
this.load_fen(s, abnormal);
},
// ---------------------------------------------------------------------------------------------------------------------
// Mouse and mouseclicks...
set_active_square: function(new_point) {
// We do this immediately so it's snappy and responsive, rather than waiting for the next draw cycle. But we don't
// want to actually call draw() here since whatever called this may well end up triggering a draw anyway.
let old_point = this.active_square;
if (old_point) {
let td = document.getElementById("underlay_" + old_point.s);
td.style["background-color"] = "transparent";
this.dirty_squares[old_point.x][old_point.y] = 0; // Lame. This is the constant for EMPTY.
}
if (new_point) {
let td = document.getElementById("underlay_" + new_point.s);
td.style["background-color"] = config.active_square;
this.dirty_squares[new_point.x][new_point.y] = 2; // Lame. This is the constant for ACTIVE.
}
this.active_square = new_point ? new_point : null;
},
boardfriends_click: function(event) {
let s = EventPathString(event, "overlay_");
let p = Point(s);
if (!p) {
return;
}
this.hide_promotiontable(); // Just in case it's up.
let ocm = this.info_handler.one_click_moves[p.x][p.y];
let board = this.tree.node.board;
if (!this.active_square && ocm && board.colour(p) !== board.active) { // Note that we test colour difference
this.set_active_square(null); // to disallow castling moves from OCM
this.move(ocm); // since the dest is the rook (which
return; // the user might want to click on.)
}
if (this.active_square) {
let move = this.active_square.s + p.s; // e.g. "e2e4" - note promotion char is handled by hub.move()
this.set_active_square(null);
let ok = this.move(move);
if (!ok && config.click_spotlight) { // No need to worry about spotlight arrows if the move actually happened
this.draw_canvas_arrows();
}
return;
}
// So there is no active_square... create one?
if (board.active === "w" && board.is_white(p)) {
this.set_active_square(p);
if (config.click_spotlight) {
this.draw_canvas_arrows();
}
}
if (board.active === "b" && board.is_black(p)) {
this.set_active_square(p);
if (config.click_spotlight) {
this.draw_canvas_arrows();
}
}
},
infobox_click: function(event) {
if (this.info_handler.clickers_are_valid_for_node(this.tree.node) === false) {
return;
}
let n = EventPathN(event, "infobox_");
let moves = this.info_handler.moves_from_click_n(n);
if (!moves || moves.length === 0) { // We do assume length > 0 below.
this.maybe_searchmove_click(event);
return;
}
// So it appears to be a real click in the infobox.........................................
// I doubt moves can be an illegal sequence now but this check is not too expensive here...
let illegal_reason = this.tree.node.board.sequence_illegal(moves);
if (illegal_reason) {
console.log("infobox_click(): " + illegal_reason);
return;
}
switch (config.pv_click_event) {
case 0:
return;
case 1:
this.tree.make_move_sequence(moves);
this.position_changed(false, true);
return;
case 2:
this.tree.add_move_sequence(moves);
return;
}
},
maybe_searchmove_click: function(event) {
let sm = EventPathString(event, "searchmove_");
if (typeof sm !== "string" || (sm.length < 4 || sm.length > 5)) {
return;
}
if (this.tree.node.searchmoves.includes(sm)) {
this.tree.node.searchmoves = this.tree.node.searchmoves.filter(move => move !== sm);
} else {
this.tree.node.searchmoves.push(sm);
}
this.tree.node.searchmoves.sort();
this.handle_search_params_change();
},
movelist_click: function(event) {
if (this.tree.handle_click(event)) {
this.position_changed(false, true);
}
},
winrate_click: function(event) {
let node = this.grapher.node_from_click(this.tree.node, event);
if (!node) {
return;
}
if (this.tree.set_node(node)) {
this.position_changed(false, true);
}
},
statusbox_click: function(event) {
if (EventPathString(event, "gobutton")) {
this.set_behaviour("analysis_free");
return;
}
if (EventPathString(event, "haltbutton")) {
this.set_behaviour("halt");
return;
}
if (EventPathString(event, "lock_return")) {
this.return_to_lock();
return;
}
if (EventPathString(event, "loadabort")) {
for (let loader of this.loaders) {
loader.shutdown();
}
return;
}
},
fullbox_click: function(event) {
let n;
// Config item editor...
if (EventPathString(event, "config_item_save") !== null) {
if (event.button !== 2) {
this.apply_fullbox_config_item_edit();
}
return;
}
if (EventPathString(event, "config_item_cancel") !== null) {
this.hide_fullbox();
return;
}
if (EventPathString(event, "config_item_web_link") !== null) {
ipcRenderer.send("web_link", this.fullbox_web_link);
return;
}
// PGN chooser...
n = EventPathN(event, "pgn_chooser_");
if (typeof n === "number") {
if (this.pgndata && n >= 0 && n < this.pgndata.count()) {
this.load_pgn_object(this.pgndata.getrecord(n));
}
return;
}
// PGN chooser, prev / next page buttons...
n = EventPathN(event, "pgn_index_chooser_");
if (typeof n !== "number") {
n = EventPathN(event, "pgn_index_b_chooser_");
}
if (typeof n === "number") {
this.pgn_choices_start = n;
this.show_pgn_chooser();
return;
}
// Engine chooser...
n = EventPathN(event, "engine_chooser_");
if (typeof n === "number") {
let filepath = this.engine_choices[n]; // The array is remade every time the fast engine chooser is displayed
if (filepath) {
if (event.button === 2) { // Right-click
if (this.engine.filepath !== filepath) {
delete engineconfig[filepath];
this.show_fast_engine_chooser();
}
} else { // Any other click
this.switch_engine(filepath);
this.hide_fullbox();
}
}
return;
}
},
promotiontable_click: function(event) {
let s = EventPathString(event, "promotion_chooser_");
this.hide_promotiontable();
this.move(s);
},
handle_file_drop: function(event) {
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0] && get_path_for_file(event.dataTransfer.files[0])) {
this.open(get_path_for_file(event.dataTransfer.files[0]));
return;
}
},
mouse_point: function() {
let overlist = document.querySelectorAll(":hover");
for (let item of overlist) {
if (typeof item.id === "string" && item.id.startsWith("overlay_")) {
return Point(item.id.slice(8)); // Possibly null
}
}
return null;
},
// ---------------------------------------------------------------------------------------------------------------------
// Settings (but NOT including UCI options)...
toggle: function(option) {
// Cases with their own handler...
if (option === "flip") {
this.toggle_flip();
return;
}
// Normal cases...
config[option] = !config[option];
// Cases that have additional actions after...
if (option === "book_explorer") {
config.lichess_explorer = false;
this.explorer_objects_cache = null;
}
if (option === "lichess_explorer") {
config.book_explorer = false;
this.explorer_objects_cache = null;
}
if (option === "look_past_25") {
if (config.look_past_25 && this.tree.node.board.fullmove > 25) {
this.looker.add_to_queue(this.tree.node.board);
}
}
if (option === "searchmoves_buttons") {
this.tree.node.searchmoves = []; // This is reasonable regardless of which way the toggle went.
this.handle_search_params_change();
}
this.info_handler.must_draw_infobox();
this.draw();
},
toggle_flip: function() { // config.flip should not be directly set, call this function instead.
config.flip = !config.flip;
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 4; y++) {
let first = document.getElementById(`overlay_${S(x, y)}`);
let second = document.getElementById(`overlay_${S(7 - x, 7 - y)}`);
SwapElements(first, second);
first = document.getElementById(`underlay_${S(x, y)}`);
second = document.getElementById(`underlay_${S(7 - x, 7 - y)}`);
SwapElements(first, second);
}
}
this.draw(); // For the canvas stuff.
},
set_arrow_filter: function(type, value) {
config.arrow_filter_type = type;
config.arrow_filter_value = value;
this.draw();
},
set_looker_api: function(value) {
if (config.looker_api === value) {
return;
}
config.looker_api = value;
if (value && value.includes("lichess") && !config.lichess_token) {
alert(messages.lichess_token_needed);
}
this.looker.clear_queue();
if (value) {
this.looker.add_to_queue(this.tree.node.board);
}
this.explorer_objects_cache = null;
},
invert_searchmoves: function() {
if (!config.searchmoves_buttons || Array.isArray(this.tree.node.searchmoves) === false) {
return;
}
// It's no disaster if the result is wrong somehow, because
// searchmoves are validated before being sent to Leela.
let moveset = Object.create(null);
for (let move of Object.keys(this.tree.node.table.moveinfo)) {
moveset[move] = true;
}
for (let move of this.tree.node.searchmoves) {
delete moveset[move];
}
this.tree.node.searchmoves = Object.keys(moveset);
this.tree.node.searchmoves.sort();
this.handle_search_params_change();
},
clear_searchmoves: function() {
this.tree.node.searchmoves = [];
this.handle_search_params_change();
},
set_pgn_font_size: function(n) {
movelist.style["font-size"] = n.toString() + "px";
fenbox.style["font-size"] = n.toString() + "px";
config.pgn_font_size = n;
config.fen_font_size = n;
},
set_arrow_size: function(width, radius, fontsize) {
config.arrow_width = width;
config.arrowhead_radius = radius;
config.board_font = `${fontsize}px Arial`;
},
set_info_font_size: function(n) {
infobox.style["font-size"] = n.toString() + "px";
statusbox.style["font-size"] = n.toString() + "px";
fullbox.style["font-size"] = n.toString() + "px";
config.info_font_size = n;
this.rebuild_sizes();
},
set_graph_height: function(sz) {
config.graph_height = sz;
this.rebuild_sizes();
this.grapher.draw(this.tree.node, true);
},
set_board_size: function(sz) {
config.square_size = Math.floor(sz / 8);
config.board_size = config.square_size * 8;
this.rebuild_sizes();
},
change_piece_set: function(directory) {
if (directory) {
if (images.validate_folder(directory) === false) {
alert(messages.invalid_pieces_directory);
return;
}
images.load_from(directory);
} else {
directory = null;
images.load_from(path.join(__dirname, "pieces"));
}
this.friendly_draws = New2DArray(8, 8, null);
this.enemy_draws = New2DArray(8, 8, null);
config["override_piece_directory"] = directory;
},
change_background: function(file, config_save = true) {
if (file && fs.existsSync(file)) {
let img = new Image();
img.src = file; // Automagically gets converted to "file:///C:/foo/bar/whatever.png"
boardsquares.style["background-image"] = `url("${img.src}")`;
} else {
boardsquares.style["background-image"] = background(config.light_square, config.dark_square, config.square_size);
}
if (config_save) {
config.override_board = file;
}
},
rebuild_sizes: function() {
// This assumes everything already exists.
// Derived from the longer version in start.js, which it does not replace.
boardfriends.width = canvas.width = boardsquares.width = config.board_size;
boardfriends.height = canvas.height = boardsquares.height = config.board_size;
rightgridder.style["height"] = `${canvas.height}px`;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
let td1 = document.getElementById("underlay_" + S(x, y));
let td2 = document.getElementById("overlay_" + S(x, y));
td1.width = td2.width = config.square_size;
td1.height = td2.height = config.square_size;
}
}
if (config.graph_height <= 0) {
graph.style.display = "none";
} else {
graph.style.height = config.graph_height.toString() + "px";
graph.style.display = "";
}
promotiontable.style.left = (boardsquares.offsetLeft + config.square_size * 2).toString() + "px";
promotiontable.style.top = (boardsquares.offsetTop + config.square_size * 3.5).toString() + "px";
promotiontable.style["background-color"] = config.active_square;
this.draw();
},
save_window_size: function() {
let zoomfactor = parseFloat(querystring.parse(global.location.search.slice(1))["zoomfactor"]);
config.width = Math.floor(window.innerWidth * zoomfactor);
config.height = Math.floor(window.innerHeight * zoomfactor);
},
set_logfile: function(filename) { // Arg can be null to stop logging.
config.logfile = null;
Log("Stopping log."); // This will do nothing, but calling Log() forces it to close any open file.
config.logfile = filename;
this.send_ack_logfile();
},
set_language: function(s) {
config.language = s;
alert(translate.t("RESTART_REQUIRED", s));
},
send_ack_logfile: function() {
ipcRenderer.send("ack_logfile", config.logfile);
},
save_config: function() {
if (!load_err1) { // If the config file was broken, never save to it, let the user fix it.
config_io.save(config);
}
},
save_engineconfig: function() {
if (!load_err2) { // If the config file was broken, never save to it, let the user fix it.
engineconfig_io.save(engineconfig);
}
},
// ---------------------------------------------------------------------------------------------------------------------
// Misc...
quit: function() {
this.engine.shutdown();
this.save_config();
this.save_engineconfig();
ipcRenderer.send("terminate");
},
set_special_message: function(s, css_class, duration) {
this.status_handler.set_special_message(s, css_class, duration);
this.draw_statusbox();
},
infobox_to_clipboard: function() {
let s = infobox.innerText;
s = ReplaceAll(s, `${config.focus_on_text} `, "");
s = ReplaceAll(s, `${config.focus_off_text} `, "");
clipboard.writeText(this.tree.node.board.fen(true) + "\n" + statusbox.innerText + "\n\n" + s);
},
send_title: function() {
let title = "Nibbler";
let root = this.tree.root;
if (root.tags && root.tags.White && root.tags.White !== "White" && root.tags.Black && root.tags.Black !== "Black") {
title += `: ${root.tags.White} - ${root.tags.Black}`;
}
ipcRenderer.send("set_title", UnsafeStringHTML(title)); // Fix any & and that sort of thing in the names.
},
generate_simple_book: function() { // For https://github.com/rooklift/lc0_lichess
let histories = this.tree.root.end_nodes().map(end => end.history_old_format());
let text_lines = histories.map(h => "\t\"" + h.join(" ") + "\"");
console.log("[\n" + text_lines.join(",\n") + "\n]");
},
run_script: function(filename) {
const disallowed = ["position", "go", "stop", "ponderhit", "quit"];
let buf;
try {
buf = fs.readFileSync(filename);
} catch (err) {
alert(err);
return;
}
this.set_behaviour("halt");
let s = buf.toString();
let lines = s.split("\n").map(z => z.trim()).filter(z => z !== "");
if (!config.allow_arbitrary_scripts) {
for (let line of lines) {
for (let d of disallowed) {
if (line.startsWith(d)) {
this.set_special_message(`${messages.invalid_script}`, "yellow");
console.log(`Refused to run script: ${filename}`);
return;
}
}
}
}
console.log(`Running script: ${filename}`);
for (let line of lines) {
if (config.allow_arbitrary_scripts) {
this.engine.send(line, true); // Force mode, so setoptions don't get held back
} else {
this.engine.send(line);
}
console.log(line);
}
this.set_special_message(`${path.basename(filename)}: Sent ${lines.length} lines`, "blue");
},
fire_gc: function() {
if (!global || !global.gc) {
alert("Unable.");
} else {
global.gc();
}
},
log_ram: function() {
console.log(`RAM after ${Math.floor(performance.now() / 1000)} seconds:`);
for (let foo of Object.entries(process.memoryUsage())) {
let type = foo[0] + " ".repeat(12 - foo[0].length);
let mb = foo[1] / (1024 * 1024);
let mb_rounded = Math.floor(mb * 1000) / 1000; // 3 d.p.
console.log(type, "(MB)", mb_rounded);
}
},
console: function(...args) {
console.log(...args);
},
toggle_debug_css: function() {
let ss = document.styleSheets[0];
let i = 0;
for (let rule of Object.values(ss.cssRules)) {
if (rule.selectorText && rule.selectorText === "*") {
ss.deleteRule(i);
return;
}
i++;
}
ss.insertRule("* {outline: 1px dotted red;}");
},
// ---------------------------------------------------------------------------------------------------------------------
// Fullbox (our full size info div)...
show_pgn_chooser: function() {
const interval = 100;
if (!this.pgndata || this.pgndata.count() === 0) {
fullbox_content.innerHTML = `No PGN loaded`;
this.show_fullbox();
return;
}
let count = this.pgndata.count();
if (this.pgn_choices_start >= count) {
this.pgn_choices_start = Math.floor((count - 1) / interval) * interval;
}
if (this.pgn_choices_start < 0) { // The most important thing, values < 0 will crash.
this.pgn_choices_start = 0;
}
let lines = [];
let max_ordinal_length = count.toString().length;
let prevnextfoo = (count > interval) ? // All these values get fixed on function entry if they're out-of-bounds. ids should be unique.
`Start |` +
` <<<< |` +
` <<< |` +
` << |` +
` >> |` +
` >>> |` +
` >>>> |` +
` End (${count}) ` +
`— ${this.pgndata.source}`
:
`${this.pgndata.source}`;
lines.push(prevnextfoo);
lines.push("");
for (let n = this.pgn_choices_start; n < this.pgn_choices_start + interval; n++) {
if (n < count) {
let pad = n < 10 ? " " : "";
let p = this.pgndata.getrecord(n);
let s;
if (p.tags.Result === "1-0") {
s = `${pad}${n}. ${p.tags.White || "Unknown"} - ${p.tags.Black || "Unknown"}`;
} else if (p.tags.Result === "0-1") {
s = `${pad}${n}. ${p.tags.White || "Unknown"} - ${p.tags.Black || "Unknown"}`;
} else {
s = `${pad}${n}. ${p.tags.White || "Unknown"} - ${p.tags.Black || "Unknown"}`;
}
if (p.tags.Opening && p.tags.Opening !== "?") {
s += ` (${p.tags.Opening})`;
} else if (p.tags.Variant && p.tags.Variant.toLowerCase() !== "standard" && p.tags.Variant.toLowerCase() !== "from position") {
s += ` (${p.tags.Variant})`;
}
lines.push(`- ${s}
`);
} else if (count > interval) { // Pad the chooser with blank lines so the buttons at the bottom behave nicely. This is stupid though.
lines.push(`- ${n}.${n === count ? " [end]" : ""}
`);
}
}
lines.push("
");
if (count > interval) {
prevnextfoo = ReplaceAll(prevnextfoo, `span id="pgn_index_chooser_`, `span id="pgn_index_b_chooser_`); // id should be unique per element.
lines.push(prevnextfoo);
}
fullbox_content.innerHTML = lines.join("");
this.show_fullbox();
},
show_sent_options: function() {
let lines = [];
lines.push(`${this.engine.filepath || "No engine loaded"}`);
lines.push("");
for (let name of Object.keys(this.engine.sent_options)) {
lines.push(`${name}
${this.engine.sent_options[name]}`);
}
fullbox_content.innerHTML = lines.join("
");
this.show_fullbox();
},
show_error_log: function() {
fullbox_content.innerHTML = this.info_handler.error_log;
this.show_fullbox();
},
parse_fullbox_config_item_value: function(item_name, raw) {
raw = raw.trim();
let defaults_has_item = Object.prototype.hasOwnProperty.call(config_io.defaults, item_name);
let expected = defaults_has_item ? config_io.defaults[item_name] : config[item_name];
if (Array.isArray(expected)) {
try {
let parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return [parsed, null];
}
return [null, "Expected JSON array"];
} catch (err) {
return [null, "Expected JSON array"];
}
}
if (typeof expected === "string") {
return [raw, null];
}
if (typeof expected === "number") {
let n = Number(raw);
if (Number.isNaN(n)) {
return [null, "Expected number"];
}
return [n, null];
}
if (typeof expected === "boolean") {
let s = raw.toLowerCase();
if (s === "true" || s === "1" || s === "yes" || s === "on") {
return [true, null];
}
if (s === "false" || s === "0" || s === "no" || s === "off") {
return [false, null];
}
return [null, `Expected boolean (true / false)`];
}
if (expected === null) { // Null defaults are usually nullable strings.
if (raw.toLowerCase() === "null") {
return [null, null];
}
return [raw, null];
}
if (typeof expected === "object") {
try {
let parsed = JSON.parse(raw);
if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed) === false) {
return [parsed, null];
}
return [null, "Expected JSON object"];
} catch (err) {
return [null, "Expected JSON object"];
}
}
return [raw, null];
},
apply_fullbox_config_item_edit: function() {
if (typeof this.fullbox_config_item !== "string") {
return;
}
let textarea = document.getElementById("config_item_input");
if (!textarea) {
return;
}
let [value, err] = this.parse_fullbox_config_item_value(this.fullbox_config_item, textarea.value);
if (err) {
let errdiv = document.getElementById("config_item_error");
if (errdiv) {
errdiv.innerHTML = `${SafeStringHTML(err)}`;
}
return;
}
config[this.fullbox_config_item] = value;
this.info_handler.must_draw_infobox();
this.draw();
this.hide_fullbox();
},
show_config_item_editor: function(item_name, web_link = null, web_text = "See web") {
if (typeof item_name !== "string" || item_name === "") {
return;
}
if (!Object.prototype.hasOwnProperty.call(config, item_name)) {
fullbox_content.innerHTML =
`Unknown config item: ${SafeStringHTML(item_name)}
` +
`Cancel`;
this.show_fullbox();
return;
}
this.fullbox_config_item = item_name;
this.fullbox_web_link = web_link;
let current = config[item_name];
let expected = Object.prototype.hasOwnProperty.call(config_io.defaults, item_name) ? config_io.defaults[item_name] : current;
let expected_type = Array.isArray(expected) ? "array" : (expected === null ? "string or null" : typeof expected);
let current_text = SafeStringHTML(stringify(current));
let initial_input;
if (typeof current === "string") {
initial_input = current;
} else if (current !== null && typeof current === "object") {
try {
initial_input = JSON.stringify(current, null, "\t");
} catch (err) {
initial_input = stringify(current);
}
} else {
initial_input = stringify(current);
}
let lines = [];
lines.push(`Editing: config.${SafeStringHTML(item_name)}
`);
lines.push(`Current: ${current_text}
`);
if (web_link) {
lines.push(`${SafeStringHTML(web_text)}:
`);
lines.push(`${SafeStringHTML(web_link)}
`);
}
lines.push(``);
lines.push(`Save | Cancel
`);
lines.push(`
`);
fullbox_content.innerHTML = lines.join("");
this.show_fullbox();
let textarea = document.getElementById("config_item_input");
if (textarea) {
textarea.value = initial_input;
textarea.focus();
textarea.select();
}
},
show_fast_engine_chooser: function() {
this.engine_choices = [];
let divs = [];
for (let filepath of Object.keys(engineconfig)) {
if (filepath === "") {
continue;
}
let ac = (this.engine.filepath === filepath) ? ` (active)` : "";
divs.push(`${path.dirname(filepath)}` +
`
${path.basename(filepath)}${ac}
`);
this.engine_choices.push(filepath); // After the above calc using length
}
if (divs.length === 0) {
divs.push(`No engines known yet.
`);
} else {
divs.unshift(`Click to load.
Right-click to remove from ${engineconfig_io.filename}.
`);
}
fullbox_content.innerHTML = divs.join("");
this.show_fullbox();
},
// ---------------------------------------------------------------------------------------------------------------------
// Showing and hiding things...
show_promotiontable: function(partial_move) {
let pieces = this.tree.node.board.active === "w" ? ["Q", "R", "B", "N"] : ["q", "r", "b", "n"];
for (let piece of pieces) {
let td = document.getElementsByClassName("promotion_" + piece.toLowerCase())[0]; // Our 4 TDs each have a unique class.
td.id = "promotion_chooser_" + partial_move + piece.toLowerCase(); // We store the actual move in the id.
td.width = config.square_size;
td.height = config.square_size;
td.style["background-image"] = images[piece].string_for_bg_style;
}
promotiontable.style.display = "block";
},
hide_promotiontable: function() {
promotiontable.style.display = "none";
},
show_fullbox: function() {
this.set_behaviour("halt");
this.hide_promotiontable();
fullbox.style.display = "block";
},
hide_fullbox: function() {
this.fullbox_config_item = null;
this.fullbox_web_link = null;
fullbox.style.display = "none";
},
escape: function() { // Set things into a clean state.
this.hide_fullbox();
this.hide_promotiontable();
if (this.active_square) {
this.set_active_square(null);
if (config.click_spotlight) {
this.draw_canvas_arrows();
}
}
},
};
================================================
FILE: files/src/renderer/97_drag.js
================================================
"use strict"
// Drag improvements submitted by ObnubiladO in PR #291
// Back in the day, something like this would be referenced in hub, but nowadays I leave such things in global space.
const drag_handler = {
drag_state: null,
cancel_drag: function() { // Must also be called after a successful drag. (Maybe misnamed, hmm?)
if (!this.drag_state) {
return;
}
if (this.drag_state.floating) { // Drag is in progress...
hub.set_active_square(null);
this.drag_state.floating.remove();
this.drag_state.floating = null; // Not strictly needed.
}
this.drag_state.from_element.style.opacity = "";
this.drag_state = null;
document.body.classList.remove("dragging-piece");
if (config.click_spotlight) {
hub.draw_canvas_arrows(); // Might need to clear spotlight arrows.
}
},
mousedown_event_on_board_td: function(overlay_td, event) {
if (event.button !== 0 || this.drag_state) {
return;
}
event.preventDefault(); // I forget why?
let piece_style = overlay_td.style.backgroundImage;
if (!piece_style || piece_style === "none") {
return;
}
let rect = overlay_td.getBoundingClientRect();
this.drag_state = {
from_element: overlay_td,
from_square: overlay_td.id.slice(8), // e.g. "e4" or similar.
piece_style: piece_style,
rect: rect,
startX: event.clientX,
startY: event.clientY,
offsetX: rect.width / 2,
offsetY: rect.height / 2,
floating: null, // The actual element - not created until we're sure we're really dragging.
};
},
mousemove_handler: function(event) {
if (!this.drag_state) {
return;
}
// I dunno if this can happen but for safety...
if (!(event.buttons & 1)) { // Bitmask: right-most bit means left click is down.
console.log("drag_handler: mousemove handler saw active drag state while button 1 up!")
this.cancel_drag();
return;
}
let dx = event.clientX - this.drag_state.startX;
let dy = event.clientY - this.drag_state.startY;
let dist = Math.hypot(dx, dy);
if (!this.drag_state.floating) {
// Treat small mouse movement as a normal click so boardfriends_click keeps its select/move behavior.
if (dist < 5) {
return;
}
// Drag starting now!
hub.set_active_square(Point(this.drag_state.from_square));
if (config.click_spotlight) {
hub.draw_canvas_arrows();
}
let floating = document.createElement("div"); // A custom ghost piece instead of HTML5 drag-and-drop.
floating.style.position = "fixed";
floating.style.pointerEvents = "none";
floating.style.width = this.drag_state.rect.width + "px";
floating.style.height = this.drag_state.rect.height + "px";
floating.style.backgroundImage = this.drag_state.piece_style;
floating.style.backgroundSize = "contain";
floating.style.backgroundRepeat = "no-repeat";
floating.style.zIndex = 1000;
document.body.appendChild(floating);
document.body.classList.add("dragging-piece"); // This is just a css change.
this.drag_state.from_element.style.opacity = "0.35";
this.drag_state.floating = floating;
}
this.drag_state.floating.style.left = (event.clientX - this.drag_state.offsetX) + "px";
this.drag_state.floating.style.top = (event.clientY - this.drag_state.offsetY) + "px";
},
mouseup_handler: function(event) {
if (hub.grapher.dragging) {
hub.grapher.dragging = false; // Always stop graph dragging.
}
if (!this.drag_state) {
return;
}
if (event.button !== 0) {
return;
}
if (this.drag_state.floating) { // Real drag was in progress...
let e = document.elementFromPoint(event.clientX, event.clientY);
let target_element = null;
while (e && e !== document.body) {
if (e.id && e.id.startsWith("overlay_")) {
target_element = e;
break;
}
e = e.parentElement;
}
if (target_element) {
let move = this.drag_state.from_square + target_element.id.slice(8);
let ok = hub.move(move);
if (!ok && config.click_spotlight) { // The spotlight needs to be cleared.
hub.draw_canvas_arrows();
}
}
}
this.cancel_drag(); // Final cleanup needed in all cases.
}
};
// Setup drag-and-drop...
window.addEventListener("mousemove", (event) => {
drag_handler.mousemove_handler(event);
});
window.addEventListener("mouseup", (event) => {
drag_handler.mouseup_handler(event);
});
window.addEventListener("drop", (event) => {
event.preventDefault();
if (drag_handler.drag_state) { // Ignore if handler is in the middle of internal piece drag.
return;
}
let dt = event.dataTransfer;
if (dt && dt.files && dt.files.length > 0) {
hub.handle_file_drop(event);
}
});
window.addEventListener("blur", () => {
drag_handler.cancel_drag();
});
window.addEventListener("mouseleave", () => {
drag_handler.cancel_drag();
});
// Native dragenter / dragover prevention is required so external file drops are accepted by the window...
window.addEventListener("dragenter", (event) => {
event.preventDefault();
});
window.addEventListener("dragover", (event) => {
event.preventDefault();
});
================================================
FILE: files/src/renderer/99_start.js
================================================
"use strict";
// Upon first run, hopefully the prefs directory exists by now
// (I think the main process makes it...)
config_io.create_if_needed(config);
engineconfig_io.create_if_needed(engineconfig);
custom_uci.create_if_needed();
Log("");
Log("======================================================================================================================================");
Log(`Nibbler startup at ${new Date().toUTCString()}`);
let hub = NewHub();
hub.engine_start(config.path, true);
if (load_err1) {
hub.err_receive(`While loading config.json: ${load_err1}`);
hub.err_receive("");
} else if (load_err2) {
hub.err_receive(`While loading engines.json: ${load_err2}`);
hub.err_receive("");
} else if (config.options) {
alert(messages.engine_options_reset);
config.args_unused = config.args;
config.options_unused = config.options;
hub.save_config(); // Ensure the options object is deleted from the file.
}
fenbox.value = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
// We have 3 main things that get drawn to:
//
// - boardsquares, lowest z-level table with enemy pieces and coloured squares.
// - canvas, which gets arrows drawn on it.
// - boardfriends, a table with friendly pieces.
//
// boardsquares has its natural position, while the other three get
// fixed position that is set to be on top of it.
boardfriends.width = canvas.width = boardsquares.width = config.board_size;
boardfriends.height = canvas.height = boardsquares.height = config.board_size;
rightgridder.style["height"] = `${canvas.height}px`;
// Set up the squares in both tables. Note that, upon flips, the elements
// themselves are moved to their new position, so everything works, e.g.
// the x and y values are still correct for the flipped view.
hub.change_background(config.override_board, false);
for (let y = 0; y < 8; y++) {
let tr1 = document.createElement("tr");
let tr2 = document.createElement("tr");
boardsquares.appendChild(tr1);
boardfriends.appendChild(tr2);
for (let x = 0; x < 8; x++) {
let td1 = document.createElement("td");
let td2 = document.createElement("td");
td1.id = "underlay_" + S(x, y);
td2.id = "overlay_" + S(x, y);
td1.width = td2.width = config.square_size;
td1.height = td2.height = config.square_size;
tr1.appendChild(td1);
tr2.appendChild(td2);
td2.addEventListener("mousedown", (event) => {
drag_handler.mousedown_event_on_board_td(td2, event);
});
}
}
statusbox.style["font-size"] = config.info_font_size.toString() + "px";
infobox.style["font-size"] = config.info_font_size.toString() + "px";
fullbox.style["font-size"] = config.info_font_size.toString() + "px";
movelist.style["font-size"] = config.pgn_font_size.toString() + "px";
fenbox.style["font-size"] = config.fen_font_size.toString() + "px";
if (config.graph_height <= 0) {
graph.style.display = "none";
} else {
graph.style.height = config.graph_height.toString() + "px";
graph.style.display = "";
}
// The promotion table pops up when needed...
promotiontable.style.left = (boardsquares.offsetLeft + config.square_size * 2).toString() + "px";
promotiontable.style.top = (boardsquares.offsetTop + config.square_size * 3.5).toString() + "px";
promotiontable.style["background-color"] = config.active_square;
// --------------------------------------------------------------------------------------------
// In bad cases of super-large trees, the UI can become unresponsive. To mitigate this, we
// put user input in a queue, and drop certain user actions if needed...
let input_queue = [];
ipcRenderer.on("set", (event, msg) => { // Should only be for things that don't need any action except redraw.
for (let [key, value] of Object.entries(msg)) {
config[key] = value;
}
hub.info_handler.must_draw_infobox();
hub.draw();
});
let droppables = [ // If the UI is already lagging, dropping one of these won't make it feel any worse.
"goto_root", "goto_end", "prev", "next", "previous_sibling", "next_sibling", "return_to_main_line", "promote_to_main_line",
"promote", "delete_node", "delete_children", "delete_siblings", "delete_other_lines", "return_to_lock", "play_info_index",
"clear_searchmoves", "invert_searchmoves",
];
ipcRenderer.on("call", (event, msg) => { // Adds stuff to the queue, or drops some stuff.
let fn;
if (typeof msg === "string") { // msg is function name
if (input_queue.length > 0 && droppables.includes(msg)) {
return;
}
fn = hub[msg].bind(hub);
} else if (typeof msg === "object" && typeof msg.fn === "string" && Array.isArray(msg.args)) { // msg is object with fn and args
if (input_queue.length > 0 && droppables.includes(msg.fn)) {
return;
}
fn = hub[msg.fn].bind(hub, ...msg.args);
} else {
console.log("Bad call, msg was...");
console.log(msg);
}
if (fn) {
input_queue.push(fn);
}
});
// The queue needs to be examined very regularly and acted upon.
function input_loop() {
if (input_queue.length > 0) {
for (let fn of input_queue) {
fn();
}
input_queue = [];
}
setTimeout(input_loop, 10);
}
input_loop();
// --------------------------------------------------------------------------------------------
// We had some problems with the various clickers: we used to destroy and create
// clickable objects a lot. This seemed to lead to moments where clicks wouldn't
// register.
//
// A better approach is to use event handlers on the outer elements, and examine
// the event.path to see what was actually clicked on.
fullbox.addEventListener("mousedown", (event) => {
hub.fullbox_click(event);
});
boardfriends.addEventListener("mousedown", (event) => {
hub.boardfriends_click(event);
});
infobox.addEventListener("mousedown", (event) => {
hub.infobox_click(event);
});
movelist.addEventListener("mousedown", (event) => {
hub.movelist_click(event);
});
statusbox.addEventListener("mousedown", (event) => {
hub.statusbox_click(event);
});
promotiontable.addEventListener("mousedown", (event) => {
hub.promotiontable_click(event);
});
// Graph clicks and dragging, borrowed from Ogatak...
graph.addEventListener("mousedown", (event) => {
hub.winrate_click(event);
hub.grapher.dragging = true;
});
for (let s of ["mousemove", "mouseleave"]) {
graph.addEventListener(s, (event) => {
if (!hub.grapher.dragging) {
return;
}
if (!event.buttons) {
hub.grapher.dragging = false;
return;
}
hub.winrate_click(event);
});
}
//
window.addEventListener("wheel", (event) => {
// Only if the PGN chooser is closed, and the mouse is over the board or graph.
// (Not over the moveslist or infobox, because those can have scroll bars, which
// the mouse wheel should interact with.)
if (fullbox.style.display !== "none") {
return;
}
// Not if the GUI has pending actions...
if (input_queue.length > 0) {
return;
}
let allow = false;
let path = event.path || (event.composedPath && event.composedPath());
if (path) {
for (let item of path) {
if (item.id === "boardfriends" || item.id === "graph") {
allow = true;
break;
}
}
}
if (allow) {
if (event.deltaY && event.deltaY < 0) input_queue.push(hub.prev.bind(hub));
if (event.deltaY && event.deltaY > 0) input_queue.push(hub.next.bind(hub));
}
});
// Setup return key on FEN box...
fenbox.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
hub.load_from_fenbox(fenbox.value);
}
});
// Set space-bar to toggle go/halt, unless we're in the FEN box...
window.addEventListener("keydown", (event) => {
if (event.key === " ") {
let ae = document.activeElement;
if (ae.tagName !== "INPUT" && ae.tagName !== "TEXTAREA" && !ae.isContentEditable) {
event.preventDefault(); // Prevent scrolling e.g. when the moves area is big enough to have a scroll bar.
if (!event.repeat) {
hub.toggle_go();
}
}
}
});
window.addEventListener("resize", (event) => {
hub.window_resize_time = performance.now();
});
window.addEventListener("error", (event) => {
alert(messages.uncaught_exception);
}, {once: true});
// Forced garbage collection. For reasons I can't begin to fathom, Node isn't
// garbage collecting everything, and the heaps seems to grow and grow. It's
// not what you would call a memory leak, since manually triggering the GC
// does clear everything... note --max-old-space-size is another option.
function force_gc() {
if (!global || !global.gc) {
console.log("Triggered GC not enabled.");
return;
}
global.gc();
setTimeout(force_gc, 300000); // Once every 5 minutes or so?
}
setTimeout(force_gc, 300000);
// Go...
function enter_loop() {
if (images.fully_loaded()) {
hub.spin();
ipcRenderer.send("renderer_ready", null);
} else {
setTimeout(enter_loop, 25);
}
}
enter_loop();