BaghchalAI / pyodide_boot.js
techaaditya's picture
Restore original Space files and editable Bot API URL
ea88929 verified
Raw History Blame Contribute Delete
10.9 kB
// Runs the REAL Python engines in the browser, on a Hugging Face *static* Space.
//
// A static Space serves files and runs nothing server-side -- but Pyodide (CPython compiled to
// WebAssembly) executes Python in the visitor's tab. Our engine ladder is pure standard library
// (no numpy, no C extensions), so it loads as-is from a zip; `phantom` here is the real PVS search,
// rather than the page's lighter JS negamax.
//
// Percentages below are agreement with the solved game on *critical* positions -- those where at
// most half the legal moves preserve the value. The unrestricted figure is not quoted because a
// random bot scores 73.5% on it (in a drawn game nearly any move holds the draw), so it flatters
// every engine and separates none of them. On critical positions random scores 27.7%.
//
// This file only *wraps* the page: it overrides botChooseMove to try Python first and defer to the
// original JS engine on any failure. If Pyodide is blocked, slow, or broken, the board still plays.
(function () {
var PYODIDE = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js";
var THINK_MS = 1200;
var ready = null; // Promise<pyodide>
var badge;
// The page builds its bot list from the Bot API, so with no server configured it offers only
// "Local engine (in-browser)" -- the JS negamax. These are the Python engines we add in that
// case. This is the classical ladder, weakest to strongest, and it stops at phantom.
//
// Deliberately absent: oracle/venom, because without the 8.7 GB solve they silently degrade to
// search and listing them would promise perfect play the browser cannot give; and sage/zero,
// because they pull in the AlphaZero package, which no longer ships in the zip at all.
//
// Keep these SHORT. The side panel is a fixed 360px column, so a long label overflows the card.
// `hint` carries the detail as a hover tooltip, which costs no width.
var PY_BOTS = [
{ name: "pebble", title: "Pebble - random", hint: "Random legal move (27.7% on critical positions)" },
{ name: "shepherd", title: "Shepherd - greedy", hint: "One-ply greedy heuristic (70.2%)" },
{ name: "hunter", title: "Hunter - negamax", hint: "Negamax, depth 3 (80.9%)" },
{ name: "stalker", title: "Stalker - TT search", hint: "Alpha-beta + transposition table (89.4%)" },
{ name: "phantom", title: "Phantom - PVS (96%)", hint: "PVS + LMR; 95.7% optimal on critical positions" },
];
var PY_NAMES = PY_BOTS.map(function (b) { return b.name; });
function status(text, tone) {
if (!badge) {
badge = document.createElement("div");
badge.style.cssText =
"position:fixed;right:12px;bottom:12px;z-index:9999;padding:6px 10px;border-radius:6px;" +
"font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;pointer-events:none;" +
"background:rgba(20,20,24,.92);color:#e8e4d9;border:1px solid rgba(255,255,255,.14)";
document.body.appendChild(badge);
}
badge.textContent = text;
badge.style.color = tone === "bad" ? "#ffb4a2" : tone === "good" ? "#b8e0b0" : "#e8e4d9";
if (tone === "good") setTimeout(function () { badge.style.opacity = "0.55"; }, 2500);
}
function loadScript(src) {
return new Promise(function (res, rej) {
var s = document.createElement("script");
s.src = src; s.onload = res; s.onerror = function () { rej(new Error("load failed: " + src)); };
document.head.appendChild(s);
});
}
async function boot() {
status("loading Python engine...");
await loadScript(PYODIDE);
var py = await loadPyodide();
status("unpacking engines...");
var resp = await fetch("bagh_engine.zip");
if (!resp.ok) throw new Error("bagh_engine.zip missing (" + resp.status + ")");
await py.unpackArchive(await resp.arrayBuffer(), "zip");
py.runPython([
"import sys, random",
"sys.path.insert(0, '/home/pyodide')",
"from bagh_core.obx import parse",
"from bagh_engine import roster",
"_rng = random.Random()",
"_cache = {}",
"def bagh_choose(obx, bot, time_ms):",
" eng = _cache.get(bot)",
" if eng is None:",
" eng = _cache[bot] = roster.build(bot)",
" if getattr(eng, 'time_limit', None) is not None:",
" eng.time_limit = time_ms / 1000.0",
" st = parse(obx).state",
" if st.winner() is not None:",
" return ''",
" return str(eng.choose(st, _rng))",
// The API preview runs the deployed server's OWN response builder, so what it shows is the
// real contract rather than a re-implementation that could drift from it.
"import json as _json",
"from bagh_server.botapi import bot_reply",
"from bagh_server.server import BotService",
"_svc = None",
"def bagh_api(obx, bot, time_ms):",
" global _svc",
" if _svc is None:",
" _svc = BotService(roster.build(bot))",
" try:",
" payload = bot_reply(_svc, obx.strip(), side=None, time_ms=time_ms,",
" tiger_bot=bot, goat_bot=bot)",
" except Exception as exc:",
" return _json.dumps({'error': str(exc), 'obx': ''}, indent=2)",
" return _json.dumps(payload, indent=2)",
].join("\n"));
status("Python engine ready", "good");
return py;
}
// --- offer the Python engines in the bot pickers ------------------------------------------
var origFetchBots = window.fetchBots;
if (typeof origFetchBots === "function") {
window.fetchBots = async function () {
await origFetchBots.apply(this, arguments);
// A real Bot API supersedes us -- it may have the tablebase, which no browser can hold.
if ((typeof serverBase === "function") && serverBase()) return;
["playBot", "tigerBot", "goatBot"].forEach(function (id) {
var sel = document.getElementById(id);
if (!sel) return;
var had = sel.value;
PY_BOTS.forEach(function (b) {
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === b.name) return;
}
var el = document.createElement("option");
el.value = b.name; el.textContent = b.title; el.title = b.hint;
sel.appendChild(el);
});
// Default to the real engine rather than the JS fallback.
sel.value = (had && had !== LOCAL) ? had : "phantom";
});
};
window.fetchBots(); // the page already ran the original; re-run to append our options
}
var original = window.botChooseMove;
if (typeof original !== "function") return; // page changed shape; leave it alone
window.botChooseMove = async function (botName) {
// A configured Bot API always wins: it may have the tablebase, which the browser cannot.
var usingServer = (typeof serverBase === "function") && serverBase();
if (!usingServer && PY_NAMES.indexOf(botName) !== -1) {
try {
if (!ready) ready = boot();
var py = await ready;
var mv = py.globals.get("bagh_choose")(serialize(state), botName, THINK_MS);
if (mv) {
var m = resolveMoveStr(state, mv); // moveStr() matches Python's Move.__str__ exactly
if (m) return m;
}
} catch (err) {
console.warn("[bagh] Python engine unavailable, using the JS fallback:", err);
status("Python engine unavailable - using JS fallback", "bad");
ready = Promise.reject(err); // do not retry the boot on every move
ready.catch(function () {});
}
}
return original(botName);
};
// --- API response preview -------------------------------------------------------------------
// Runs the deployed server's own bot_reply() in the browser, so you can see the exact JSON the
// Bot API returns for a position. It is a PREVIEW, not an endpoint: a static Space serves files
// and runs no server, so baghchal.net cannot call this page -- it has to call a hosted API.
function apiPanel() {
// The side column is <aside class="panel">. Never fall back to .layout: it is the grid
// itself, so appending there would add a third column and break the page.
var host = document.querySelector("aside.panel");
if (!host) return;
var card = document.createElement("div");
card.className = "card";
card.innerHTML =
'<h2>API response <span style="opacity:.6;font-weight:400">· preview</span></h2>' +
'<div class="src" style="margin-bottom:8px">The exact JSON the Bot API returns, produced by' +
' the server\'s own code running here in your browser. A static Space cannot be called by' +
' another site — point baghchal.net at a hosted API.</div>' +
'<textarea id="apiObx" rows="2" spellcheck="false" style="width:100%;min-width:0;' +
'box-sizing:border-box;font-family:var(--mono);font-size:11.5px;padding:7px 9px;' +
'border-radius:7px;border:1px solid var(--panel-2);background:var(--bg);color:var(--ink)">' +
'TXXXT/XXXXX/XXXXX/XXXXX/TXXXT g @20 c0 -</textarea>' +
'<div class="ctl-line"><button id="apiSend" class="act primary" type="button">Send</button>' +
'<span id="apiNote" class="src" style="margin:0"></span></div>' +
'<pre id="apiOut" style="white-space:pre-wrap;word-break:break-word;overflow-x:auto;' +
'font-size:11.5px;margin-top:10px;padding:9px;border-radius:7px;background:var(--bg);' +
'border:1px solid var(--panel-2)">—</pre>';
host.appendChild(card);
document.getElementById("apiSend").addEventListener("click", async function () {
var out = document.getElementById("apiOut");
var note = document.getElementById("apiNote");
var bot = (document.getElementById("playBot") || {}).value;
if (PY_NAMES.indexOf(bot) === -1) bot = "phantom";
out.textContent = "thinking...";
note.textContent = "";
try {
var py = await ready;
var obx = document.getElementById("apiObx").value;
var t0 = performance.now();
out.textContent = py.globals.get("bagh_api")(obx, bot, THINK_MS);
note.textContent = bot + " · " + Math.round(performance.now() - t0) + "ms";
} catch (err) {
out.textContent = "Python engine unavailable: " + err;
}
});
}
// Start downloading Pyodide now rather than on the first move: it is ~10 MB, and paying that
// mid-game would look like the engine hanging. Failures are handled in botChooseMove.
if (!ready) {
ready = boot();
ready.catch(function () {});
}
try { apiPanel(); } catch (e) { console.warn("[bagh] API panel not added:", e); }
})();