ansarzeinulla's picture
initial
2cf2375
Raw
History Blame Contribute Delete
37.2 kB
"""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'<option value="{l["code"]}">{l["flag"]} {l["name"]}</option>' for l in LANGUAGES
)
INDEX_MARKUP = r"""
<style>
#oracle_in, #oracle_out, #oracle_btn { display: none !important; }
footer { display: none !important; } /* hide Gradio's "Use via API · Built with Gradio" bar */
.gradio-container { max-width: 100% !important; width: 100% !important; padding: 4px !important; }
.gradio-container .main, .gradio-container .wrap, .gradio-container .contain { max-width: 100% !important; }
/* strip Gradio's own horizontal padding so the board can use the full width */
.fillable, .main.fillable, .app { padding-left: 6px !important; padding-right: 6px !important; }
.html-container { padding: 0 !important; }
.block.padded { padding: 0 !important; }
#bb-wrap { max-width: 1700px; margin: 0 auto; padding: 0 8px;
font-family: system-ui, sans-serif; color: #2a2118; --bs: 42px; }
#bb-wrap * { box-sizing: border-box; }
#bb-cols { display: flex; gap: 16px; align-items: stretch; }
#bb-main { position: relative; flex: 1 1 auto; min-width: 0; display: flex; align-items: flex-start; }
#bb-side { flex: 0 0 250px; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
#bb-cols.side-hidden #bb-side { display: none; }
/* collapse / expand the right column */
#bb-toggle { position: absolute; top: 10px; right: 10px; z-index: 6; width: 34px; height: 34px;
border-radius: 10px; border: 1px solid #b98f52; background: rgba(255,250,235,0.9);
color: #6d500e; font-size: 18px; font-weight: 800; cursor: pointer; line-height: 1;
display: flex; align-items: center; justify-content: center; }
#bb-toggle:hover { background: #fff4d6; }
@media (max-width: 860px) {
#bb-wrap { padding: 0 6px; }
#bb-cols { flex-direction: column; gap: 12px; }
#bb-side { flex: 1 1 auto; width: 100%; }
.bb-board { padding: 10px; border-radius: 18px; gap: 6px; }
.bb-cellrow, .bb-numrow { gap: 5px; }
.bb-cell { padding: 4px; border-radius: 9px; }
.bb-numrow .n { font-size: 14px; }
.bb-kazan { padding: 6px 10px; gap: 8px; }
.bb-kcount { font-size: 18px; }
.bb-hist { min-height: 220px; }
#bb-toggle { top: 6px; right: 6px; }
}
.bb-card { background: #fffdf5; border: 1px solid #ecdfb8; border-radius: 16px; padding: 14px;
display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
/* ---- board: a real Bestemshe board with kumalak stones ---- */
.bb-board { flex: 1 1 auto; width: 100%;
background: linear-gradient(160deg, #ddba86 0%, #c99f68 100%);
border: 2px solid #a67c48; border-radius: 22px; padding: 16px;
box-shadow: inset 0 2px 10px rgba(120,80,30,0.20), 0 6px 18px rgba(120,80,20,0.16);
display: flex; flex-direction: column; gap: 8px; }
.ball { position: relative; display: inline-block; width: var(--bs); height: var(--bs); border-radius: 50%;
background: radial-gradient(circle at 34% 30%, #fffdf6 0%, #eed9a6 46%, #bd9051 100%);
box-shadow: inset 0 -2px 4px rgba(90,60,20,0.40), 0 1px 2px rgba(0,0,0,0.28); }
.ball.ghost { background: none; box-shadow: none; }
/* stones inside the play cells sit a touch smaller than their slot for breathing room */
.bb-cell .ball { transform: scale(0.9); }
/* a stacked (overlaid) layer of stones is shown as a dark dot at the centre */
.ball .ov { position: absolute; top: 25%; left: 25%; width: 50%; height: 50%; border-radius: 50%;
background: #241a0d; box-shadow: inset 0 1px 1px rgba(255,255,255,0.18); }
/* kazans (stores) — lighter, no name labels */
.bb-kazan { --ks: clamp(16px, calc(var(--bs) * 0.58), 32px); /* kazan stones stay compact */
display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-radius: 16px;
background: rgba(255,250,235,0.28); border: 2px solid rgba(120,80,30,0.28);
height: calc(var(--ks) * 2 + 20px); /* FIXED — never changes with stone count */
flex: 0 0 auto; transition: box-shadow .15s, border-color .15s; }
.bb-kazan.turn { border-color: #d99a1f; box-shadow: 0 0 0 1px rgba(217,154,31,0.5), 0 0 14px rgba(217,154,31,0.35); }
.bb-kcount { font-weight: 800; font-size: 22px; color: #5a3d18; min-width: 34px; text-align: center;
background: rgba(120,80,30,0.14); border-radius: 12px; padding: 3px 10px;
font-variant-numeric: tabular-nums; }
.bb-kballs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; flex: 1 1 auto;
overflow: hidden; } /* one row of columns only — kazan height stays fixed */
.bb-kballs .ball { width: var(--ks); height: var(--ks); }
.bb-kcol { display: flex; flex-direction: column; gap: 4px; height: calc(var(--ks) * 2 + 4px); }
.bb-kazan.kazan-black .bb-kcol { justify-content: flex-start; } /* gravity top */
.bb-kazan.kazan-white .bb-kcol { justify-content: flex-end; } /* gravity down */
/* count sub-rows between cells and kazans */
.bb-numrow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; padding: 0 2px; }
.bb-numrow .n { text-align: center; font-weight: 800; font-size: 17px; color: #5a3d18;
font-variant-numeric: tabular-nums; }
/* the five cells of each side — each exactly 2 stones wide, 5 tall */
.bb-cellrow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
.bb-board { overflow-x: hidden; }
.bb-cell { border-radius: 12px; border: 2px solid rgba(90,60,25,0.30);
background: rgba(70,45,18,0.16); padding: 6px;
height: calc(var(--bs) * 5 + 36px); display: flex;
transition: box-shadow .15s, border-color .15s, background .12s; }
.bb-cell.mv { cursor: pointer; }
.bb-cell.mv:hover { background: rgba(70,45,18,0.26); }
.bb-cell.WIN { border-color: #2fa740; box-shadow: 0 0 0 1px rgba(47,167,64,0.6), 0 0 14px rgba(47,167,64,0.55); }
.bb-cell.DRAW { border-color: #e8c400; box-shadow: 0 0 0 1px rgba(232,196,0,0.65), 0 0 14px rgba(232,196,0,0.6); }
.bb-cell.LOSS { border-color: #e23b3b; box-shadow: 0 0 0 1px rgba(226,59,59,0.6), 0 0 14px rgba(226,59,59,0.55); }
.bb-stack { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.bb-cellrow.row-black .bb-stack { justify-content: flex-start; } /* grows downward */
.bb-cellrow.row-white .bb-stack { justify-content: flex-end; } /* grows upward */
.bb-brow { display: flex; gap: 6px; justify-content: center; } /* 2 columns, left gravity */
.bb-side-row { display: flex; gap: 8px; align-items: center; }
#bb-langsel { width: 100%; font-size: 15px; padding: 9px 10px; border-radius: 12px;
border: 1px solid #e2c98a; background: #fffdf5; color: #2a2118; cursor: pointer; }
.bb-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.bb-icon { cursor: pointer; border: 1px solid #e2c98a; background: #fff8e6; border-radius: 12px;
height: 46px; font-size: 22px; font-weight: 800; color: #8a5a00; padding: 0;
line-height: 1; display: flex; align-items: center; justify-content: center;
font-family: system-ui, sans-serif; }
.bb-icon:hover { background: #ffedc2; color: #5c3c00; }
#bb-btn-setup { font-size: 27px; } /* the gear reads optically small — nudge it up */
.bb-hist-title { font-weight: 700; color: #6d500e; margin: 0 0 8px; font-size: 14px;
text-transform: uppercase; letter-spacing: .04em; }
.bb-hist { font-family: ui-monospace, monospace; font-size: 15px;
flex: 1 1 0; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.bb-hrow { display: grid; grid-template-columns: 34px 1fr 1fr; gap: 4px; align-items: center;
line-height: 1.5; padding: 2px 0; }
.bb-turn { color: #b3a276; text-align: right; padding-right: 4px; }
.bb-mv { cursor: pointer; padding: 3px 8px; border-radius: 7px; text-align: center; }
.bb-mv:hover { background: #ffedc2; }
.bb-mv.on { background: #d9822b; color: #fff; }
.bb-overlay { position: fixed; inset: 0; background: rgba(30,22,8,0.5); display: none;
align-items: center; justify-content: center; z-index: 1000; }
.bb-overlay.open { display: flex; }
.bb-modal { background: #fffdf8; border-radius: 18px; padding: 24px; max-width: 70%; width: 100%;
max-height: 88vh; overflow: auto; box-shadow: 0 14px 48px rgba(0,0,0,0.35); }
.bb-modal h3 { margin: 0 0 14px; color: #6d500e; }
.bb-modal textarea { width: 100%; font-family: ui-monospace, monospace; font-size: 15px;
padding: 10px; border: 1px solid #d8c692; border-radius: 10px; background: #fff; }
.bb-modal .hint { font-size: 15.5px; color: #4c4130; margin: 10px 0 4px; line-height: 1.75; }
#bb-fen-help { background: #fbf3dc; border: 1px solid #ecdfb8; border-radius: 10px;
padding: 12px 14px; margin-top: 12px; }
#bb-fen-help b { color: #6d500e; }
.bb-modal .hint b { color: #6d500e; }
.bb-modal .keys { display: grid; grid-template-columns: 74px 1fr; gap: 6px 12px;
font-size: 14.5px; color: #4c4130; margin: 10px 0; align-items: center; }
.bb-modal .keys kbd { background: #f1e5c0; border: 1px solid #d8c692; border-radius: 6px;
padding: 2px 8px; font-family: ui-monospace, monospace; font-size: 13px;
text-align: center; }
.bb-modal .err { color: #c62828; font-size: 14px; margin-top: 8px; min-height: 18px; }
.bb-modal .credit { font-size: 13px; color: #6d500e; margin-top: 14px; line-height: 1.6;
border-top: 1px solid #ecdfb8; padding-top: 12px; }
.bb-modal .btns { display: flex; gap: 10px; justify-content: stretch; margin-top: 16px; }
/* Start and Close buttons are forced to identical size/height. */
.bb-modal .btns button { flex: 1 1 0; cursor: pointer; border-radius: 10px; padding: 0;
height: 46px; line-height: 46px; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 15px; border: 1px solid #d9822b;
background: #d9822b; color: #fff; }
.bb-modal .btns button:hover { background: #c4741f; }
.bb-end-title { text-align: center; font-size: 22px; }
</style>
<div id="bb-wrap">
<div id="bb-cols">
<div id="bb-main">
<button id="bb-toggle" title="Hide / show panel">⇥</button>
<div class="bb-board" id="bb-board"></div>
</div>
<div id="bb-side">
<select id="bb-langsel">__LANG_OPTIONS__</select>
<div class="bb-actions">
<button class="bb-icon" data-act="new" id="bb-btn-new">↺</button>
<button class="bb-icon" data-act="open" data-modal="bb-setup" id="bb-btn-setup">⚙</button>
<button class="bb-icon" data-act="open" data-modal="bb-help" id="bb-btn-help">?</button>
<button class="bb-icon" data-act="pgn" id="bb-btn-pgn">⤓</button>
</div>
<div class="bb-card">
<div class="bb-hist-title" id="bb-hist-title"></div>
<div class="bb-hist" id="bb-hist"></div>
</div>
</div>
</div>
<div class="bb-overlay" id="bb-setup"><div class="bb-modal" style="max-width: 50%">
<h3 id="bb-setup-title"></h3>
<textarea id="bb-fen" rows="2"></textarea>
<div class="hint" id="bb-fen-help"></div>
<div class="err" id="bb-setup-err"></div>
<div class="btns"><button data-act="close" data-modal="bb-setup" id="bb-setup-close"></button>
<button data-act="apply" id="bb-setup-apply"></button></div>
</div></div>
<div class="bb-overlay" id="bb-help"><div class="bb-modal">
<h3 id="bb-help-title"></h3>
<div class="hint" id="bb-help-intro"></div>
<div class="keys" id="bb-help-keys"></div>
<div class="hint" id="bb-help-extra"></div>
<div class="credit" id="bb-help-credit"></div>
<div class="btns"><button data-act="close" data-modal="bb-help" id="bb-help-close"></button></div>
</div></div>
<div class="bb-overlay" id="bb-end"><div class="bb-modal">
<h3 class="bb-end-title" id="bb-end-title"></h3>
<div class="hint" id="bb-end-msg" style="text-align:center"></div>
<div class="btns"><button data-act="close" data-modal="bb-end" id="bb-end-close"></button>
<button data-act="pgn" id="bb-end-pgn"></button></div>
</div></div>
</div>
""".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 += '<span class="ball ghost"></span>';
else s += '<span class="ball">' + (layers >= 2 ? '<i class="ov"></i>' : "") + "</span>";
}
rows.push('<div class="bb-brow">' + s + "</div>");
}
if (side === "white") rows.reverse();
return '<div class="bb-stack">' + rows.join("") + "</div>";
}
// 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 += '<span class="ball"></span>';
cols.push('<div class="bb-kcol">' + b + "</div>");
}
return cols.join("");
}
function cellHtml(count, pit, isMover, move, side) {
const cls = "bb-cell" + (isMover && move ? " mv " + move.result : "");
const data = isMover && move ? ' data-act="pit" data-i="' + pit + '"' : "";
return '<div class="' + cls + '"' + data + ">" + cellStack(count, side) + "</div>";
}
// 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 += '<div class="n">' + cv.bp[bp - 1] + "</div>";
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 += '<div class="n">' + cv.wp[wp - 1] + "</div>";
}
const blackTurn = cv.whiteToMove ? "" : " turn";
const whiteTurn = cv.whiteToMove ? " turn" : "";
document.getElementById("bb-board").innerHTML =
'<div class="bb-kazan kazan-black' + blackTurn + '">' +
'<span class="bb-kcount">' + cv.bk + '</span>' +
'<div class="bb-kballs">' + kazanStack(cv.bk) + "</div></div>" +
'<div class="bb-numrow">' + blackNums + "</div>" +
'<div class="bb-cellrow row-black">' + blackCells + "</div>" +
'<div class="bb-cellrow row-white">' + whiteCells + "</div>" +
'<div class="bb-numrow">' + whiteNums + "</div>" +
'<div class="bb-kazan kazan-white' + whiteTurn + '">' +
'<span class="bb-kcount">' + cv.wk + '</span>' +
'<div class="bb-kballs">' + kazanStack(cv.wk) + "</div></div>";
sizeBoard();
let hist = "";
for (let i = 0; i < played.length; i += 2) {
hist += '<div class="bb-hrow"><span class="bb-turn">' + (i / 2 + 1) + ".</span>";
for (let j = i; j < i + 2; j++) {
hist += (j < played.length)
? '<span class="bb-mv' + (j + 1 === cursor ? " on" : "") + '" data-act="hist" data-idx="' + j + '">' + played[j] + "</span>"
: "<span></span>";
}
hist += "</div>";
}
const histEl = document.getElementById("bb-hist");
histEl.innerHTML = hist;
document.getElementById("bb-hist-title").textContent = t("history");
const onEl = histEl.querySelector(".bb-mv.on");
if (onEl) onEl.scrollIntoView({ block: "nearest" });
else histEl.scrollTop = histEl.scrollHeight;
// toolbar tooltips + modal labels
document.getElementById("bb-btn-new").title = t("newGame");
document.getElementById("bb-btn-setup").title = t("setup");
document.getElementById("bb-btn-help").title = t("help");
document.getElementById("bb-btn-pgn").title = t("pgn");
document.getElementById("bb-setup-title").textContent = t("setup");
document.getElementById("bb-fen-help").innerHTML = t("fenHelp");
document.getElementById("bb-setup-apply").textContent = t("apply");
document.getElementById("bb-setup-close").textContent = t("close");
document.getElementById("bb-help-title").textContent = t("help");
document.getElementById("bb-help-intro").innerHTML = t("helpIntro");
document.getElementById("bb-help-keys").innerHTML =
t("keys").map((k) => "<kbd>" + k[0] + "</kbd><span>" + k[1] + "</span>").join("");
document.getElementById("bb-help-extra").textContent = t("helpExtra");
document.getElementById("bb-help-credit").textContent = t("authors");
document.getElementById("bb-help-close").textContent = t("close");
document.getElementById("bb-end-close").textContent = t("close");
document.getElementById("bb-end-pgn").textContent = t("pgn");
}
function anyModalOpen() {
return document.querySelector(".bb-overlay.open") !== null;
}
// ---- events ------------------------------------------------------------ #
document.getElementById("bb-langsel").addEventListener("change", (e) => { lang = e.target.value; render(); });
document.getElementById("bb-toggle").addEventListener("click", () => {
const cols = document.getElementById("bb-cols");
const hidden = cols.classList.toggle("side-hidden");
document.getElementById("bb-toggle").textContent = hidden ? "⇤" : "⇥";
sizeBoard(); // board width changed — rescale stones
});
document.getElementById("bb-wrap").addEventListener("click", async (e) => {
const el = e.target.closest("[data-act]"); if (!el) return;
const act = el.dataset.act;
if (act === "new") { await newGame(DEFAULT_FEN); }
else if (act === "pgn") { downloadPgn(); }
else if (act === "open") {
const m = document.getElementById(el.dataset.modal);
if (el.dataset.modal === "bb-setup") {
document.getElementById("bb-fen").value = nodeToFen(line[cursor]);
document.getElementById("bb-setup-err").textContent = "";
}
m.classList.add("open");
}
else if (act === "close") { document.getElementById(el.dataset.modal).classList.remove("open"); }
else if (act === "apply") {
const fen = document.getElementById("bb-fen").value.trim();
const err = await newGame(fen);
if (err) document.getElementById("bb-setup-err").textContent = t("fenHelp");
else document.getElementById("bb-setup").classList.remove("open");
}
else if (act === "pit") { await inputIndex(parseInt(el.dataset.i, 10)); }
else if (act === "hist") { go(parseInt(el.dataset.idx, 10) + 1); }
});
document.querySelectorAll(".bb-overlay").forEach((o) => o.addEventListener("click", (e) => {
if (e.target === o) o.classList.remove("open");
}));
// Kazakh keyboard number row maps to the same digit inputs (item 5.2).
const KZ_DIGIT = { "«": "1", "ә": "2", "і": "3", "ң": "4", "ғ": "5", "ұ": "9", "қ": "0" };
// WASD navigation, case-insensitive, plus the letters those keys type on a
// Kazakh/Russian layout: W→ц, A→ф, S→ы, D→в (items 5.3 / 5.4).
const NAV = { w: "up", a: "left", s: "down", d: "right",
"ц": "up", "ф": "left", "ы": "down", "в": "right" };
document.addEventListener("keydown", (e) => {
if (anyModalOpen()) { if (e.key === "Escape") document.querySelectorAll(".bb-overlay.open").forEach((o) => o.classList.remove("open")); return; }
const tag = (document.activeElement && document.activeElement.tagName) || "";
if (tag === "TEXTAREA" || tag === "INPUT" || tag === "SELECT") return;
let key = e.key;
if (KZ_DIGIT[key] !== undefined) key = KZ_DIGIT[key]; // normalise Kazakh row to digits
const nav = NAV[key.toLowerCase()]; // WASD / Kazakh nav (case-insensitive)
if ((key >= "0" && key <= "5") || key === "9") { inputIndex(parseInt(key, 10)); e.preventDefault(); }
else if (e.key === "ArrowRight" || nav === "right") { go(cursor + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft" || nav === "left") { go(cursor - 1); e.preventDefault(); }
else if (e.key === "ArrowUp" || nav === "up") { go(0); e.preventDefault(); }
else if (e.key === "ArrowDown" || nav === "down") { go(line.length - 1); e.preventDefault(); }
});
const boot = setInterval(() => {
if (document.querySelector("#oracle_in textarea") && document.querySelector("#oracle_btn")) {
clearInterval(boot); newGame(DEFAULT_FEN);
}
}, 100);
}
""".replace("__I18N_JSON__", json.dumps(TRANSLATIONS, ensure_ascii=False))
ensure_query_binary()
ensure_tablebase()
with gr.Blocks(title="Bestemshe — Perfect-Play Explorer", fill_width=True) as demo:
gr.HTML(INDEX_MARKUP)
inp = gr.Textbox(elem_id="oracle_in")
out = gr.Textbox(elem_id="oracle_out")
btn = gr.Button("bridge", elem_id="oracle_btn")
btn.click(oracle_bridge, inp, out)
demo.load(None, None, None, js=INDEX_JS)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))