"""Bestemshe Perfect-Play Explorer.
A custom HTML/JS front end (multilingual, keyboard-driven, rewritable history)
talks to the C++ tablebase oracle through a hidden Gradio bridge. Keeping the
UI client-side lets us do global keyboard handling, arrow navigation, clickable
history and language switching that Gradio's own components cannot express.
All user-facing copy, flags and language names live in i18n.py.
"""
import json
import os
import subprocess
import gradio as gr
from i18n import LANGUAGES, TRANSLATIONS
# CPU-only app. If the Space is provisioned on ZeroGPU hardware its startup
# check demands a @spaces.GPU function; this dummy probe (never called) satisfies
# it while all real work runs on the always-available CPU.
try:
import spaces
@spaces.GPU
def _zerogpu_probe():
return None
except ImportError:
pass
QUERY_BIN = os.environ.get("BESTEMSHE_QUERY_BIN", "./query")
DATA_DIR = os.environ.get("BESTEMSHE_DATA_DIR", "layers/compressed")
TABLEBASE_DATASET = os.environ.get("BESTEMSHE_DATASET", "ansarzeinulla/bestemshe-tablebase")
def ensure_tablebase():
"""The 8.96GB tablebase lives in a dataset repo (Space repos are capped at 1GB)."""
if os.path.isdir(DATA_DIR) and any(f.endswith(".bin") for f in os.listdir(DATA_DIR)):
return
from huggingface_hub import snapshot_download
print(f"[setup] downloading tablebase from {TABLEBASE_DATASET} ...")
snapshot_download(repo_id=TABLEBASE_DATASET, repo_type="dataset", local_dir=".")
print("[setup] tablebase ready.")
def ensure_query_binary():
"""On HF Gradio Spaces there is no Docker build step: compile at startup."""
if os.path.isfile(QUERY_BIN) and os.access(QUERY_BIN, os.X_OK):
return
print("[setup] compiling query CLI...")
subprocess.run(
["g++", "-std=c++17", "-O3", "-DNDEBUG", "query.cpp", "-o", "query", "-lzstd"],
check=True,
)
print("[setup] query CLI ready.")
def parse_fields(text):
parts = text.split()
if len(parts) != 12:
raise ValueError("Enter 12 numbers.")
try:
vals = [int(p) for p in parts]
except ValueError:
raise ValueError("All values must be whole numbers.")
if any(v < 0 or v > 50 for v in vals):
raise ValueError("Each number must be between 0 and 50.")
if sum(vals) != 50:
raise ValueError(f"The stones must total 50 (yours total {sum(vals)}).")
if vals[0] % 2 or vals[1] % 2:
raise ValueError("Kazan totals must be even.")
if vals[0] > 24 or vals[1] > 24:
raise ValueError("A kazan of 26+ means the game is already over.")
return vals
def run_query(state_str):
env = dict(os.environ, BESTEMSHE_DATA_DIR=DATA_DIR)
proc = subprocess.run(
[QUERY_BIN] + state_str.split(),
capture_output=True, text=True, timeout=60, env=env,
)
return json.loads(proc.stdout)
def oracle_bridge(payload):
"""payload = 'nonce|K1 K2 p0..p9'. Returns JSON string with the same nonce."""
nonce, _, state = payload.partition("|")
try:
parse_fields(state)
data = run_query(state)
if "error" in data:
return json.dumps({"nonce": nonce, "ok": False, "error": data["error"]})
return json.dumps({"nonce": nonce, "ok": True, "data": data})
except ValueError as e:
return json.dumps({"nonce": nonce, "ok": False, "error": str(e)})
except Exception as e: # noqa: BLE001
return json.dumps({"nonce": nonce, "ok": False, "error": str(e)})
# --------------------------------------------------------------------------- #
# Front end
# --------------------------------------------------------------------------- #
LANG_OPTIONS = "".join(
f'' for l in LANGUAGES
)
INDEX_MARKUP = r"""
""".replace("__LANG_OPTIONS__", LANG_OPTIONS)
INDEX_JS = r"""
() => {
if (window.__bbInit) return; window.__bbInit = true;
const DEFAULT_FEN = "5,5,5,5,5/5,5,5,5,5 0,0 w 1";
const I18N = __I18N_JSON__;
const SITE = "https://huggingface.co/spaces/ansarzeinulla/Bestemshe-God-Algorithm";
let lang = "EN";
let initialFen = DEFAULT_FEN;
let line = []; // nodes {state, ply, moveIn, key, moves, gameOver, winner}
let played = []; // notations, aligned to line transitions
let cursor = 0;
let busy = false;
const t = (k) => (I18N[lang][k] !== undefined ? I18N[lang][k] : I18N.EN[k]);
// Two roles by side index: 0 = the first player (moves first), 1 = the follower.
// Names are translated, so winners are tracked by side index, not by name.
const nameFor = (side) => (side === 0 ? t("nameFirst") : t("nameSecond"));
// ---- FEN <-> internal state -------------------------------------------- #
// Internal state is mover-relative "K1 K2 p0..p9" (K1/p0-4 = side to move).
// FEN is colour-absolute: w1..w5/b1..b5 wKazan,bKazan side moveNo (White=Bastaushi).
function absView(state, ply) {
const v = state.split(" ").map(Number);
const kM = v[0], kO = v[1], b = v.slice(2);
if (ply % 2 === 0) return { bk: kM, kk: kO, bp: b.slice(0, 5), kp: b.slice(5, 10), moverBottom: true };
return { bk: kO, kk: kM, bp: b.slice(5, 10), kp: b.slice(0, 5), moverBottom: false };
}
function nodeToFen(node) {
const av = absView(node.state, node.ply);
const side = node.ply % 2 === 0 ? "w" : "b";
const moveNo = Math.floor(node.ply / 2) + 1;
return av.bp.join(",") + "/" + av.kp.join(",") + " " +
av.bk + "," + av.kk + " " + side + " " + moveNo;
}
// Returns {state, ply} or {error}.
function fenToInternal(fen) {
try {
const parts = fen.trim().split(/\s+/);
if (parts.length < 3) return { error: "bad" };
const cells = parts[0].split("/");
if (cells.length !== 2) return { error: "bad" };
const white = cells[0].split(",").map(Number);
const black = cells[1].split(",").map(Number);
const kaz = parts[1].split(",").map(Number);
if (white.length !== 5 || black.length !== 5 || kaz.length !== 2) return { error: "bad" };
const all = white.concat(black, kaz);
if (all.some((n) => !Number.isInteger(n) || n < 0)) return { error: "bad" };
const side = (parts[2] || "w").toLowerCase();
const moveNo = parts[3] !== undefined ? parseInt(parts[3], 10) : 1;
const wk = kaz[0], bk = kaz[1];
let state, ply;
if (side === "b") { // Black (Kostaushi) to move
state = bk + " " + wk + " " + black.concat(white).join(" ");
ply = (Math.max(1, moveNo) - 1) * 2 + 1;
} else { // White (Bastaushi) to move
state = wk + " " + bk + " " + white.concat(black).join(" ");
ply = (Math.max(1, moveNo) - 1) * 2;
}
return { state, ply };
} catch (e) { return { error: "bad" }; }
}
// ---- oracle bridge ----------------------------------------------------- #
function setNative(el, val) {
const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
set.call(el, val); el.dispatchEvent(new Event("input", { bubbles: true }));
}
function oracleOnce(state) {
return new Promise((resolve, reject) => {
const nonce = Math.random().toString(36).slice(2);
const inp = document.querySelector("#oracle_in textarea");
const out = document.querySelector("#oracle_out textarea");
const btn = document.querySelector("#oracle_btn button") || document.querySelector("#oracle_btn");
if (!inp || !out || !btn) { reject("bridge not ready"); return; }
let done = false;
const poll = setInterval(() => {
try { const j = JSON.parse(out.value); if (j.nonce === nonce) { done = true; clearInterval(poll); resolve(j); } } catch (e) {}
}, 70);
setTimeout(() => { if (!done) { clearInterval(poll); reject("timeout"); } }, 6000);
setNative(inp, nonce + "|" + state);
btn.click();
});
}
async function oracle(state) {
let lastErr;
for (let attempt = 0; attempt < 4; attempt++) {
try { return await oracleOnce(state); }
catch (e) { lastErr = e; await new Promise((r) => setTimeout(r, 250)); }
}
throw lastErr;
}
async function makeNode(state, ply, moveIn) {
const node = { state, ply, moveIn, key: (ply % 2) + ":" + state, moves: [], gameOver: null, winner: null };
const res = await oracle(state);
if (!res.ok) { node.error = res.error; return node; }
node.moves = res.data.moves;
if (node.moves.length === 0) { node.gameOver = "nomove"; node.winner = (ply + 1) % 2; }
return node;
}
async function newGame(fen) {
const parsed = fenToInternal(fen);
if (parsed.error) return "bad";
const n0 = await makeNode(parsed.state, parsed.ply, null);
if (n0.error) return n0.error;
initialFen = fen; line = [n0]; played = []; cursor = 0; render();
return null;
}
async function playMove(node, m) {
if (busy) return; busy = true;
try {
line = line.slice(0, cursor + 1);
played = played.slice(0, cursor);
const notation = "" + m.from + m.to + (m.capture ? "+" : "");
const cs = m.child.K1 + " " + m.child.K2 + " " + m.child.board.join(" ");
const cp = node.ply + 1;
let child;
if (m.terminal) {
child = { state: cs, ply: cp, moveIn: notation, key: (cp % 2) + ":" + cs,
moves: [], gameOver: "win", winner: node.ply % 2 };
} else {
// Loop detection: we keep every position (side-to-move + board) of the
// current line; if this child repeats one of them, it is an infinite-loop draw.
const repeat = line.some((nd) => nd.key === (cp % 2) + ":" + cs);
child = await makeNode(cs, cp, notation);
child.moveIn = notation;
if (repeat) { child.gameOver = "draw"; child.winner = null; child.moves = []; }
}
line.push(child); played.push(notation); cursor = line.length - 1; render();
if (child.gameOver) announceEnd(child);
} finally { busy = false; }
}
function optimal(node) {
const rank = { WIN: 2, DRAW: 1, LOSS: 0 };
let best = -1; node.moves.forEach((m) => { best = Math.max(best, rank[m.result]); });
return node.moves.filter((m) => rank[m.result] === best);
}
async function inputIndex(i) {
const node = line[cursor];
if (!node || node.gameOver || node.error) return;
if (i === 0) { // random OPTIMAL move
const b = optimal(node);
if (b.length) await playMove(node, b[Math.floor(Math.random() * b.length)]);
return;
}
if (i === 9) { // random move — optimality ignored
if (node.moves.length) await playMove(node, node.moves[Math.floor(Math.random() * node.moves.length)]);
return;
}
const m = node.moves.find((x) => x.from === i);
if (m) await playMove(node, m);
}
function go(idx) { cursor = Math.max(0, Math.min(line.length - 1, idx)); render(); }
// ---- PGN --------------------------------------------------------------- #
function resultToken() {
const last = line[line.length - 1];
if (last.gameOver === "draw") return "1/2-1/2";
if (last.gameOver === "win" || last.gameOver === "nomove")
return last.winner === 0 ? "1-0" : "0-1";
return "*";
}
function pgnMoves() {
let out = [];
for (let i = 0; i < played.length; i += 2) {
out.push((i / 2 + 1) + ". " + played.slice(i, i + 2).join(" "));
}
let body = out.join(" ");
const last = line[line.length - 1];
if (last.gameOver === "draw") body += " {Loop is reached}";
const res = resultToken();
if (res !== "*") body += " " + res;
return body;
}
function downloadPgn() {
const d = new Date();
const date = d.getFullYear() + "." +
String(d.getMonth() + 1).padStart(2, "0") + "." + String(d.getDate()).padStart(2, "0");
const header =
'[Event "Single Play Versus God"]\n' +
'[Site "' + SITE + '"]\n' +
'[Date "' + date + '"]\n' +
'[White "Player"]\n' +
'[Black "God"]\n' +
'[Result "' + resultToken() + '"]\n' +
'[PlyCount "' + played.length + '"]\n' +
'[Annotator "God"]\n' +
'[Mode "online"]\n';
const pgn = header + "\n" + pgnMoves() + "\n";
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([pgn], { type: "text/plain" }));
a.download = "bestemshe.pgn"; a.click();
}
// ---- rendering --------------------------------------------------------- #
// Colour-absolute view of a node (White = Bastaushi, Black = Kostaushi).
function colorView(node) {
const v = node.state.split(" ").map(Number);
const k1 = v[0], k2 = v[1], p = v.slice(2);
const whiteToMove = node.ply % 2 === 0;
let wk, bk, wp, bp;
if (whiteToMove) { wk = k1; bk = k2; wp = p.slice(0, 5); bp = p.slice(5, 10); }
else { bk = k1; wk = k2; bp = p.slice(0, 5); wp = p.slice(5, 10); }
return { wk, bk, wp, bp, whiteToMove };
}
// A cell is exactly 2 stones wide and 5 stones tall — 10 visible slots.
// Stones fill row by row, left column first (an odd stone sits on the left).
// `side` sets the growth direction: black grows downward (rows top→bottom),
// white grows upward (rows bottom→top). Once a slot is full, further stones
// stack a new layer on top of it — shown as a small dark dot in the stone's
// centre rather than a second circle. Slot i holds ceil((n-i)/10) layers.
function cellStack(n, side) {
let rows = [];
for (let r = 0; r < 5; r++) {
let s = "";
for (let c = 0; c < 2; c++) {
const i = r * 2 + c;
const layers = n > i ? Math.ceil((n - i) / 10) : 0;
if (layers <= 0) s += '';
else s += '' + (layers >= 2 ? '' : "") + "";
}
rows.push('
' + s + "
");
}
if (side === "white") rows.reverse();
return '
' + rows.join("") + "
";
}
// A kazan holds stones in columns of 2, filled column by column (left→right).
// Vertical gravity (top for black, bottom for white) is handled in CSS.
function kazanStack(n) {
let cols = [];
const ncols = Math.ceil(n / 2);
for (let c = 0; c < ncols; c++) {
const inCol = Math.min(2, n - c * 2);
let b = "";
for (let k = 0; k < inCol; k++) b += '';
cols.push('
";
}
// Each cell is 2 stones wide; the five cells fill the board's width, so the
// stone size is whatever makes 2 of them fit snugly inside one cell. Recomputed
// on every render and on resize (keeps stones as large as the space allows).
function sizeBoard() {
const board = document.getElementById("bb-board");
const wrap = document.getElementById("bb-wrap");
if (!board || !wrap) return;
const cs = getComputedStyle(board);
const inner = board.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const cellGap = 8, cellPad = 6, colGap = 6; // must match the CSS
const cellOuter = (inner - 4 * cellGap) / 5;
let bs = Math.floor((cellOuter - 2 * cellPad - colGap) / 2); // largest that fits the width
bs = Math.max(14, Math.min(120, bs));
wrap.style.setProperty("--bs", bs + "px");
// Then measure the real board height and shrink until it fits the device viewport.
for (let i = 0; i < 5; i++) {
const avail = window.innerHeight - board.getBoundingClientRect().top - 12;
const h = board.offsetHeight;
if (h <= avail || bs <= 14) break;
bs = Math.max(14, Math.floor(bs * avail / h));
wrap.style.setProperty("--bs", bs + "px");
}
}
window.addEventListener("resize", sizeBoard);
function announceEnd(node) {
const titleEl = document.getElementById("bb-end-title");
const msgEl = document.getElementById("bb-end-msg");
if (node.gameOver === "draw") {
titleEl.textContent = "🔁 " + t("drawLoop");
msgEl.textContent = t("loopHint");
} else {
titleEl.textContent = "🏆 " + nameFor(node.winner) + " " + t("wins");
msgEl.textContent = "";
}
document.getElementById("bb-end").classList.add("open");
}
function render() {
const node = line[cursor];
const cv = colorView(node);
const moverMoves = new Map();
if (!node.gameOver && !node.error) node.moves.forEach((m) => moverMoves.set(m.from, m));
// Fixed board: Black (Kostaushi) on top, White (Bastaushi) on the bottom.
// Each player numbers pits 1..5 from their own left, so Black's pits read
// right-to-left across the top; White's read left-to-right along the bottom.
let blackCells = "", blackNums = "", whiteCells = "", whiteNums = "";
for (let c = 1; c <= 5; c++) {
const bp = 6 - c; // black pit at visual column c
const bMover = !cv.whiteToMove;
blackCells += cellHtml(cv.bp[bp - 1], bp, bMover, moverMoves.get(bp), "black");
blackNums += '
' + cv.bp[bp - 1] + "
";
const wp = c; // white pit at visual column c
const wMover = cv.whiteToMove;
whiteCells += cellHtml(cv.wp[wp - 1], wp, wMover, moverMoves.get(wp), "white");
whiteNums += '