BaghchalAI / index.html
techaaditya's picture
Restore original Space files and editable Bot API URL
ea88929 verified
Raw History Blame Contribute Delete
38.3 kB
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Baghchal Analysis Board</title>
<style>
:root {
--bg: #e9ebe6;
--panel: #f3f4f0;
--panel-2: #e0e3dc;
--ink: #1c2126;
--ink-soft: #55606a;
--ink-faint: #8b949d;
--line: #b0824a; /* brass board lines */
--board: #d9d2c2; /* board field */
--board-edge: #c3b79f;
--tiger: #d8912b; /* amber */
--goat: #4f8f7e; /* jade */
--tiger-soft: #f0d6a3;
--goat-soft: #bcdad0;
--win: #4f9d5f;
--loss: #c65b52;
--draw: #8b9096;
--hint: #6a92c9;
--shadow: 0 1px 2px rgba(20,25,30,.12), 0 6px 20px rgba(20,25,30,.08);
--radius: 10px;
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1418; --panel: #161c22; --panel-2: #1e262d;
--ink: #e6eaee; --ink-soft: #9aa6b0; --ink-faint: #667079;
--line: #c08c4e; --board: #22292e; --board-edge: #2f3941;
--tiger: #e0a13c; --goat: #6fb0a0; --tiger-soft: #4a3c22; --goat-soft: #234039;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 26px rgba(0,0,0,.35);
}
}
:root[data-theme="light"] {
--bg: #e9ebe6; --panel: #f3f4f0; --panel-2: #e0e3dc;
--ink: #1c2126; --ink-soft: #55606a; --ink-faint: #8b949d;
--line: #b0824a; --board: #d9d2c2; --board-edge: #c3b79f;
--tiger: #d8912b; --goat: #4f8f7e; --tiger-soft: #f0d6a3; --goat-soft: #bcdad0;
--shadow: 0 1px 2px rgba(20,25,30,.12), 0 6px 20px rgba(20,25,30,.08);
}
:root[data-theme="dark"] {
--bg: #0f1418; --panel: #161c22; --panel-2: #1e262d;
--ink: #e6eaee; --ink-soft: #9aa6b0; --ink-faint: #667079;
--line: #c08c4e; --board: #22292e; --board-edge: #2f3941;
--tiger: #e0a13c; --goat: #6fb0a0; --tiger-soft: #4a3c22; --goat-soft: #234039;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 26px rgba(0,0,0,.35);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--sans); line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1140px; margin: 0 auto; padding: 28px 22px 60px; }
header.masthead {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--panel-2);
padding-bottom: 16px; margin-bottom: 22px;
}
.brand { display: flex; align-items: baseline; gap: 12px; }
.brand h1 {
font-family: var(--serif); font-weight: 600; font-size: 30px; margin: 0;
letter-spacing: .2px;
}
.brand .sub {
font-size: 12px; text-transform: uppercase; letter-spacing: .14em;
color: var(--ink-faint);
}
.theme-btn {
font: inherit; font-size: 13px; color: var(--ink-soft); cursor: pointer;
background: var(--panel); border: 1px solid var(--panel-2);
border-radius: 999px; padding: 6px 14px;
}
.theme-btn:hover { color: var(--ink); }
/* minmax(0,1fr) on the board column lets it shrink; the side column is fixed so the controls
keep a stable width. 380px fits the bot-picker labels without crowding the board. */
.layout { display: grid; grid-template-columns: minmax(0,1fr) 380px; gap: 26px; align-items: start; }
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
/* ---- board ---- */
.board-card {
background: var(--panel); border: 1px solid var(--panel-2);
border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow);
}
.status-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; gap: 10px; flex-wrap: wrap;
}
.turn-pill {
display: inline-flex; align-items: center; gap: 8px; font-size: 13px;
font-weight: 600; padding: 5px 12px; border-radius: 999px;
background: var(--panel-2); color: var(--ink);
}
.turn-pill .dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.t { background: var(--tiger); }
.dot.g { background: var(--goat); }
.meta { font-size: 12.5px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.meta b { color: var(--ink); font-weight: 600; }
svg.board { width: 100%; height: auto; display: block; touch-action: manipulation; }
.pt-hit { cursor: pointer; }
.banner {
margin-top: 12px; padding: 10px 14px; border-radius: 8px; font-size: 14px;
font-weight: 600; text-align: center; display: none;
}
.banner.show { display: block; }
.banner.tiger { background: color-mix(in srgb, var(--tiger) 22%, var(--panel)); color: var(--ink); }
.banner.goat { background: color-mix(in srgb, var(--goat) 26%, var(--panel)); color: var(--ink); }
/* ---- eval bar ---- */
.evalbar-wrap { margin: 4px 0 2px; }
.evalbar {
position: relative; height: 16px; border-radius: 999px; overflow: hidden;
background: var(--goat); border: 1px solid var(--panel-2);
}
.evalbar .fill { position: absolute; inset: 0 auto 0 0; background: var(--tiger); transition: width .35s ease; }
.evalbar .mid { position: absolute; top: -3px; bottom: -3px; left: 50%; width: 1px; background: var(--ink-faint); opacity: .5; }
.eval-legend { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-faint); margin-top: 5px; text-transform: uppercase; letter-spacing: .08em; }
/* ---- panel ---- */
.panel { display: flex; flex-direction: column; gap: 16px; }
.card {
background: var(--panel); border: 1px solid var(--panel-2);
border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow);
}
.card h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: .13em;
color: var(--ink-faint); margin: 0 0 12px; font-weight: 700;
}
.btnrow { display: flex; flex-wrap: wrap; gap: 8px; }
button.act {
font: inherit; font-size: 13px; font-weight: 550; cursor: pointer;
background: var(--panel-2); color: var(--ink); border: 1px solid transparent;
border-radius: 8px; padding: 8px 12px; transition: background .15s, border-color .15s;
}
button.act:hover { border-color: var(--line); }
button.act.primary { background: var(--tiger); color: #1c1305; }
button.act.primary:hover { filter: brightness(1.05); }
button.act:focus-visible, .theme-btn:focus-visible, input:focus-visible { outline: 2px solid var(--hint); outline-offset: 2px; }
button.act:disabled { opacity: .45; cursor: default; }
.ctl-line { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--ink-soft); margin-top: 12px; }
.ctl-line input[type=range] { flex: 1; accent-color: var(--tiger); }
.ctl-line input[type=text] {
flex: 1; min-width: 0; font: inherit; font-family: var(--mono); font-size: 12px;
padding: 7px 9px; border-radius: 7px; border: 1px solid var(--panel-2);
background: var(--bg); color: var(--ink);
}
.ctl-line select.sel {
/* min-width:0 is load-bearing: a flex item defaults to min-width:auto, so a long <option>
label refuses to shrink and pushes the select past the edge of its card. */
flex: 1; min-width: 0; width: 100%; text-overflow: ellipsis;
font: inherit; font-size: 12.5px; cursor: pointer;
padding: 7px 9px; border-radius: 7px; border: 1px solid var(--panel-2);
background: var(--bg); color: var(--ink);
}
.ctl-line label { min-width: 66px; }
.think {
display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px;
color: var(--ink-soft); font-weight: 600;
}
.think .spin {
width: 11px; height: 11px; border-radius: 50%;
border: 2px solid var(--panel-2); border-top-color: var(--tiger);
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .think .spin { animation: none; } }
/* move ranking */
.moves { display: flex; flex-direction: column; gap: 5px; max-height: 260px; overflow-y: auto; }
.move {
display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px;
padding: 7px 10px; border-radius: 7px; background: var(--bg);
border: 1px solid transparent; cursor: pointer; font-size: 13px;
}
.move:hover { border-color: var(--line); }
.move.best { border-color: var(--tiger); }
.move .mv { font-family: var(--mono); font-weight: 600; }
.move .val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink-soft); font-size: 12px; }
.tag { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 999px; }
.tag.win { background: color-mix(in srgb, var(--win) 22%, transparent); color: var(--win); }
.tag.loss { background: color-mix(in srgb, var(--loss) 22%, transparent); color: var(--loss); }
.tag.draw { background: color-mix(in srgb, var(--draw) 22%, transparent); color: var(--draw); }
.pv { font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); word-spacing: 2px; line-height: 1.7; }
.obx-out { font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft); word-break: break-all; background: var(--bg); padding: 9px 11px; border-radius: 7px; border: 1px solid var(--panel-2); }
.src { color: var(--ink-faint); font-size: 11px; margin-top: 8px; }
.src b { color: var(--goat); font-weight: 700; }
a { color: var(--hint); }
</style>
<div class="wrap">
<header class="masthead">
<div class="brand">
<h1>Baghchal</h1>
<span class="sub">Analysis Board</span>
</div>
<button class="theme-btn" id="themeToggle" type="button">Theme</button>
</header>
<div class="layout">
<!-- BOARD -->
<section class="board-card">
<div class="status-row">
<span class="turn-pill"><span class="dot t" id="turnDot"></span><span id="turnText">Goat to place</span></span>
<span class="meta">In hand <b id="mHand">20</b> &nbsp;·&nbsp; Captured <b id="mCap">0</b> / 5</span>
</div>
<div class="evalbar-wrap">
<div class="evalbar"><div class="fill" id="evalFill" style="width:50%"></div><div class="mid"></div></div>
<div class="eval-legend"><span style="color:var(--tiger)">Tigers</span><span id="evalNum">even</span><span style="color:var(--goat)">Goats</span></div>
</div>
<svg class="board" id="board" viewBox="0 0 440 440" role="img" aria-label="Baghchal board"></svg>
<div class="banner" id="banner"></div>
</section>
<!-- PANEL -->
<aside class="panel">
<div class="card">
<h2>Game mode</h2>
<div class="btnrow" id="modeRow">
<button class="act primary" data-mode="analysis" type="button">Analysis</button>
<button class="act" data-mode="play" type="button">Play vs Bot</button>
<button class="act" data-mode="watch" type="button">Watch Bots</button>
</div>
<!-- Play vs Bot -->
<div id="playCtl" style="display:none">
<div class="ctl-line">
<label for="playBot">Opponent</label>
<select id="playBot" class="sel"></select>
</div>
<div class="ctl-line">
<label>You play</label>
<div class="btnrow">
<button class="act primary" id="sideGoat" type="button">Goat</button>
<button class="act" id="sideTiger" type="button">Tiger</button>
</div>
</div>
<div class="ctl-line" id="playStatus" style="color:var(--ink-soft)">Your move.</div>
</div>
<!-- Watch Bots -->
<div id="watchCtl" style="display:none">
<div class="ctl-line"><label for="tigerBot">Tiger bot</label><select id="tigerBot" class="sel"></select></div>
<div class="ctl-line"><label for="goatBot">Goat bot</label><select id="goatBot" class="sel"></select></div>
<div class="ctl-line">
<label for="watchSpeed">Delay</label>
<input type="range" id="watchSpeed" min="0" max="1500" value="500">
<span id="watchSpeedVal" style="font-family:var(--mono);font-size:12px">500ms</span>
</div>
<div class="btnrow">
<button class="act primary" id="watchStart" type="button">Start</button>
<button class="act" id="watchStep" type="button">Step</button>
</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="btnrow">
<button class="act primary" id="btnEngine" type="button">Engine move</button>
<button class="act" id="btnUndo" type="button">Undo</button>
<button class="act" id="btnNew" type="button">New game</button>
</div>
<div class="ctl-line">
<label for="depth">Search depth</label>
<input type="range" id="depth" min="1" max="4" value="2">
<span id="depthVal" style="font-family:var(--mono)">2</span>
</div>
<div class="ctl-line">
<label for="serverUrl" title="Optional: point at a running bagh-server for named bots + exact tablebase / venom analysis">Bot&nbsp;API</label>
<input type="text" id="serverUrl" placeholder="http://127.0.0.1:8080 (optional)">
</div>
</div>
<div class="card">
<h2>Move ranking <span id="rankSrc" style="color:var(--ink-faint);font-weight:500;text-transform:none;letter-spacing:0">· local eval</span></h2>
<div class="moves" id="moveList"></div>
</div>
<div class="card">
<h2>Principal variation</h2>
<div class="pv" id="pv">—</div>
</div>
<div class="card">
<h2>OBX position</h2>
<div class="obx-out" id="obxOut"></div>
<div class="btnrow" style="margin-top:10px">
<button class="act" id="btnCopy" type="button">Copy</button>
<button class="act" id="btnLoad" type="button">Load from clipboard</button>
</div>
<div class="src">Self-contained: legal-move generation, negamax eval, and OBX I/O all run in the browser. Set a <b>Bot&nbsp;API</b> URL to upgrade ranking to the exact WDL / trap engine.</div>
</div>
</aside>
</div>
</div>
<script>
"use strict";
// ---------- board geometry (mirrors bagh_core/board.py) ----------
const SIDE = 5, N = 25;
const rc = p => [Math.floor(p / SIDE), p % SIDE];
const idx = (r, c) => r * SIDE + c;
const inB = (r, c) => r >= 0 && r < SIDE && c >= 0 && c < SIDE;
const hasDiag = p => { const [r, c] = rc(p); return (r + c) % 2 === 0; };
const ORTHO = [[-1,0],[1,0],[0,-1],[0,1]];
const DIAG = [[-1,-1],[-1,1],[1,-1],[1,1]];
const ADJ = [], JUMPS = [];
for (let p = 0; p < N; p++) {
const [r, c] = rc(p);
const dirs = hasDiag(p) ? ORTHO.concat(DIAG) : ORTHO;
const nb = [];
for (const [dr, dc] of dirs) if (inB(r+dr, c+dc)) nb.push(idx(r+dr, c+dc));
ADJ.push(nb);
}
for (let p = 0; p < N; p++) {
const [r, c] = rc(p); const js = [];
for (const [dr, dc] of ORTHO.concat(DIAG)) {
const orr = r+dr, oc = c+dc, lr = r+2*dr, lc = c+2*dc;
if (!inB(orr, oc) || !inB(lr, lc)) continue;
const over = idx(orr, oc), land = idx(lr, lc);
if (ADJ[p].includes(over) && ADJ[over].includes(land)) js.push([over, land]);
}
JUMPS.push(js);
}
const START_TIGERS = [0, 4, 20, 24];
const coord = p => { const [r, c] = rc(p); return "ABCDE"[c] + (r + 1); };
// ---------- state ----------
function initial() { return { tigers: new Set(START_TIGERS), goats: new Set(), turn: "G", hand: 20, cap: 0 }; }
const clone = s => ({ tigers: new Set(s.tigers), goats: new Set(s.goats), turn: s.turn, hand: s.hand, cap: s.cap });
const occ = (s, p) => s.tigers.has(p) || s.goats.has(p);
function legalMoves(s) {
const out = [];
if (s.turn === "T") {
for (const t of s.tigers) {
for (const a of ADJ[t]) if (!occ(s, a)) out.push({ kind: "step", src: t, dst: a });
for (const [over, land] of JUMPS[t]) if (s.goats.has(over) && !occ(s, land)) out.push({ kind: "capture", src: t, dst: land, over });
}
} else {
if (s.hand > 0) { for (let p = 0; p < N; p++) if (!occ(s, p)) out.push({ kind: "place", dst: p }); }
else for (const g of s.goats) for (const a of ADJ[g]) if (!occ(s, a)) out.push({ kind: "step", src: g, dst: a });
}
return out;
}
function apply(s, m) {
const n = clone(s);
if (m.kind === "place") { n.goats.add(m.dst); n.hand--; }
else if (m.kind === "step") { const set = n.turn === "T" ? n.tigers : n.goats; set.delete(m.src); set.add(m.dst); }
else { n.tigers.delete(m.src); n.tigers.add(m.dst); n.goats.delete(m.over); n.cap++; }
n.turn = n.turn === "T" ? "G" : "T";
return n;
}
function winner(s) {
if (s.cap >= 5) return "T";
if (legalMoves(s).length === 0) return s.turn === "T" ? "G" : "T";
return null;
}
const moveStr = m => m.kind === "place" ? coord(m.dst) : m.kind === "capture" ? coord(m.src) + "x" + coord(m.dst) : coord(m.src) + "-" + coord(m.dst);
// ---------- OBX ----------
function serialize(s, last) {
let rows = [];
for (let r = 0; r < SIDE; r++) { let row = ""; for (let c = 0; c < SIDE; c++) { const p = idx(r, c); row += s.tigers.has(p) ? "T" : s.goats.has(p) ? "G" : "X"; } rows.push(row); }
return rows.join("/") + " " + (s.turn === "T" ? "t" : "g") + " @" + s.hand + " c" + s.cap + " " + (last || "-");
}
function parseObx(text) {
const tok = text.trim().split(/\s+/);
const rows = tok[0].split("/");
if (rows.length !== SIDE || rows.some(r => r.length !== SIDE)) throw new Error("board must be 5 rows of 5");
const s = { tigers: new Set(), goats: new Set(), turn: "G", hand: 0, cap: 0 };
for (let r = 0; r < SIDE; r++) for (let c = 0; c < SIDE; c++) { const ch = rows[r][c], p = idx(r, c); if (ch === "T") s.tigers.add(p); else if (ch === "G") s.goats.add(p); else if (ch !== "X") throw new Error("cell must be T/G/X"); }
s.turn = (tok[1] || "g").toLowerCase() === "t" ? "T" : "G";
s.hand = parseInt((tok[2] || "@0").slice(1)) || 0;
s.cap = parseInt((tok[3] || "c0").slice(1)) || 0;
return s;
}
// ---------- evaluation (tiger POV, mirrors the spirit of eval.py) ----------
function evaluate(s) {
let v = 0;
v += s.cap * 300;
// tiger mobility + capture threats
let tmob = 0, threats = 0;
for (const t of s.tigers) {
for (const a of ADJ[t]) if (!occ(s, a)) tmob++;
for (const [over, land] of JUMPS[t]) if (s.goats.has(over) && !occ(s, land)) threats++;
}
v += tmob * 2 + threats * 30;
// central control (C3 + its neighbours)
const CENTER = [12, 7, 11, 13, 17, 6, 8, 16, 18];
for (const t of s.tigers) if (CENTER.includes(t)) v += 4;
// goat safety: goats defended by an adjacent goat are worth more (penalty for tigers)
let safe = 0, exposed = 0;
for (const g of s.goats) {
let def = false; for (const a of ADJ[g]) if (s.goats.has(a)) { def = true; break; }
if (def) safe++; else exposed++;
}
v -= safe * 6; v -= exposed * 2;
v -= s.hand * 3; // goats in hand = latent structure for the goat side
return v;
}
const MATE = 1e6;
function evalStm(s) { const v = evaluate(s); return s.turn === "T" ? v : -v; }
function negamax(s, depth, alpha, beta, ply) {
const w = winner(s);
if (w !== null) return -(MATE - ply);
if (depth === 0) return evalStm(s);
let best = -MATE - 1;
const moves = legalMoves(s).sort((a, b) => (b.kind === "capture") - (a.kind === "capture"));
for (const m of moves) {
const val = -negamax(apply(s, m), depth - 1, -beta, -alpha, ply + 1);
if (val > best) best = val;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
}
return best;
}
// rank every legal move from side-to-move POV
function rankMoves(s, depth) {
const rows = legalMoves(s).map(m => {
const val = -negamax(apply(s, m), depth - 1, -MATE - 1, MATE + 1, 1);
return { m, val };
});
rows.sort((a, b) => b.val - a.val);
return rows;
}
function principalVariation(s, depth, maxLen) {
const pv = []; let cur = s;
for (let i = 0; i < maxLen; i++) {
if (winner(cur) !== null) break;
const rows = rankMoves(cur, Math.max(1, depth - i > 1 ? depth : depth));
if (!rows.length) break;
pv.push(rows[0].m); cur = apply(cur, rows[0].m);
}
return pv;
}
// ---------- rendering ----------
const NS = "http://www.w3.org/2000/svg";
const M = 40, CELL = 90; // margin, cell size -> 40 + 4*90 = 400, +40 = 440 viewBox
const xy = p => { const [r, c] = rc(p); return [M + c * CELL, M + r * CELL]; };
const svg = document.getElementById("board");
let state = initial();
let history = [];
let selected = null; // selected source point (movement)
let destMap = new Map(); // dst point -> move (for the current selection / placement)
let lastMove = null;
function drawStaticEdges() {
const seen = new Set();
for (let p = 0; p < N; p++) for (const q of ADJ[p]) {
const key = Math.min(p, q) + "-" + Math.max(p, q);
if (seen.has(key)) continue; seen.add(key);
const [x1, y1] = xy(p), [x2, y2] = xy(q);
const ln = document.createElementNS(NS, "line");
ln.setAttribute("x1", x1); ln.setAttribute("y1", y1);
ln.setAttribute("x2", x2); ln.setAttribute("y2", y2);
ln.setAttribute("stroke", "var(--line)"); ln.setAttribute("stroke-width", "1.6");
ln.setAttribute("stroke-linecap", "round"); ln.setAttribute("opacity", ".75");
svg.appendChild(ln);
}
}
function render() {
svg.innerHTML = "";
// board field
const bg = document.createElementNS(NS, "rect");
bg.setAttribute("x", M - 18); bg.setAttribute("y", M - 18);
bg.setAttribute("width", CELL * 4 + 36); bg.setAttribute("height", CELL * 4 + 36);
bg.setAttribute("rx", 12); bg.setAttribute("fill", "var(--board)");
bg.setAttribute("stroke", "var(--board-edge)");
svg.appendChild(bg);
drawStaticEdges();
// last-move highlight
if (lastMove) {
for (const pt of [lastMove.src, lastMove.dst]) {
if (pt === undefined || pt < 0) continue;
const [x, y] = xy(pt);
const h = document.createElementNS(NS, "circle");
h.setAttribute("cx", x); h.setAttribute("cy", y); h.setAttribute("r", 26);
h.setAttribute("fill", "var(--hint)"); h.setAttribute("opacity", ".16");
svg.appendChild(h);
}
}
// move destination hints
for (const dst of destMap.keys()) {
const [x, y] = xy(dst);
const ring = document.createElementNS(NS, "circle");
const capt = destMap.get(dst).kind === "capture";
ring.setAttribute("cx", x); ring.setAttribute("cy", y);
ring.setAttribute("r", capt ? 24 : 12);
ring.setAttribute("fill", capt ? "none" : "var(--hint)");
ring.setAttribute("opacity", capt ? "1" : ".55");
if (capt) { ring.setAttribute("stroke", "var(--loss)"); ring.setAttribute("stroke-width", "3"); }
svg.appendChild(ring);
}
// points + pieces
for (let p = 0; p < N; p++) {
const [x, y] = xy(p);
const node = document.createElementNS(NS, "circle");
node.setAttribute("cx", x); node.setAttribute("cy", y); node.setAttribute("r", 5.5);
node.setAttribute("fill", "var(--line)"); node.setAttribute("opacity", ".55");
svg.appendChild(node);
if (state.tigers.has(p) || state.goats.has(p)) {
const isT = state.tigers.has(p);
const g = document.createElementNS(NS, "circle");
g.setAttribute("cx", x); g.setAttribute("cy", y); g.setAttribute("r", 26);
g.setAttribute("fill", isT ? "var(--tiger)" : "var(--goat)");
g.setAttribute("stroke", selected === p ? "var(--ink)" : "rgba(0,0,0,.18)");
g.setAttribute("stroke-width", selected === p ? 3 : 1.5);
svg.appendChild(g);
const label = document.createElementNS(NS, "text");
label.setAttribute("x", x); label.setAttribute("y", y + 6);
label.setAttribute("text-anchor", "middle"); label.setAttribute("font-size", "20");
label.setAttribute("font-family", "var(--serif)"); label.setAttribute("font-weight", "600");
label.setAttribute("fill", isT ? "#1c1305" : "#0d1f1a");
label.textContent = isT ? "T" : "G";
svg.appendChild(label);
}
// hit target
const hit = document.createElementNS(NS, "circle");
hit.setAttribute("cx", x); hit.setAttribute("cy", y); hit.setAttribute("r", 30);
hit.setAttribute("fill", "transparent"); hit.setAttribute("class", "pt-hit");
hit.addEventListener("click", () => onPoint(p));
svg.appendChild(hit);
}
updateStatus();
}
function updateStatus() {
document.getElementById("mHand").textContent = state.hand;
document.getElementById("mCap").textContent = state.cap;
const dot = document.getElementById("turnDot");
dot.className = "dot " + (state.turn === "T" ? "t" : "g");
const w = winner(state);
const banner = document.getElementById("banner");
if (w) {
banner.className = "banner show " + (w === "T" ? "tiger" : "goat");
banner.textContent = (w === "T" ? "Tigers win" : "Goats win") + " — game over";
document.getElementById("turnText").textContent = "Game over";
} else {
banner.className = "banner";
document.getElementById("turnText").textContent =
state.turn === "T" ? "Tiger to move" : (state.hand > 0 ? "Goat to place" : "Goat to move");
}
document.getElementById("obxOut").textContent = serialize(state, lastMove ? moveStr(lastMove) : null);
}
// ---------- interaction ----------
function onPoint(p) {
if (winner(state)) return;
if (botThinking || gameMode === "watch") return; // board is bot-driven
if (gameMode === "play" && state.turn !== userSide) return; // not your turn
if (destMap.has(p)) { doMove(destMap.get(p)); return; }
// (re)select a source
if (state.turn === "G" && state.hand > 0) { selected = null; destMap.clear(); render(); return; }
const mine = state.turn === "T" ? state.tigers : state.goats;
if (mine.has(p)) {
selected = p; destMap.clear();
for (const m of legalMoves(state)) if (m.src === p) destMap.set(m.dst, m);
render();
} else { selected = null; destMap.clear(); render(); }
}
function doMove(m) {
history.push(clone(state));
state = apply(state, m);
lastMove = m; selected = null; destMap.clear();
render(); analyze();
scheduleAuto();
}
function refreshPlacementHints() {
destMap.clear();
if (state.turn === "G" && state.hand > 0 && !winner(state))
for (const m of legalMoves(state)) destMap.set(m.dst, m);
}
// ---------- analysis ----------
let analyzeTimer = null;
function analyze() {
refreshPlacementHints();
render();
const url = document.getElementById("serverUrl").value.trim();
clearTimeout(analyzeTimer);
analyzeTimer = setTimeout(() => (url ? analyzeServer(url) : analyzeLocal()), 60);
}
function setEvalBar(scoreTigerCp) {
const p = 1 / (1 + Math.exp(-scoreTigerCp / 220)); // logistic squashing
document.getElementById("evalFill").style.width = (p * 100).toFixed(1) + "%";
const num = document.getElementById("evalNum");
num.textContent = Math.abs(scoreTigerCp) < 15 ? "even" : (scoreTigerCp > 0 ? "+" : "−") + Math.abs(Math.round(scoreTigerCp));
}
function analyzeLocal() {
document.getElementById("rankSrc").textContent = "· local eval";
const depth = +document.getElementById("depth").value;
if (winner(state)) { renderMoves([]); setEvalBar(winner(state) === "T" ? 9999 : -9999); document.getElementById("pv").textContent = "—"; return; }
const rows = rankMoves(state, depth);
// eval bar wants tiger POV; rows[0].val is side-to-move POV
const bestStm = rows.length ? rows[0].val : 0;
const tigerCp = state.turn === "T" ? bestStm : -bestStm;
setEvalBar(Math.max(-9999, Math.min(9999, tigerCp)));
// classify by mate score for the tag
const view = rows.map(r => {
let tag = null;
if (r.val > MATE - 1000) tag = "win";
else if (r.val < -(MATE - 1000)) tag = "loss";
return { mv: moveStr(r.m), val: r.val, tag, move: r.m };
});
renderMoves(view);
const pv = principalVariation(state, depth, 6);
document.getElementById("pv").textContent = pv.length ? pv.map(moveStr).join(" ") : "—";
}
async function analyzeServer(url) {
const src = document.getElementById("rankSrc");
try {
const res = await fetch(url.replace(/\/$/, "") + "/analyze", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ obx: serialize(state) })
});
if (!res.ok) throw new Error("no tablebase");
const data = await res.json();
src.textContent = "· exact (tablebase / venom)";
const view = data.moves.map(r => ({
mv: r.move, val: (r.value === "WIN" ? "#" + r.dtw : r.value === "LOSS" ? "-#" + r.dtw : "draw") + " trap " + Math.round(r.traps * 100) + "%",
tag: r.value.toLowerCase(), move: null, raw: r
}));
renderMoves(view);
const top = data.moves[0];
setEvalBar(top ? (top.value === "WIN" ? 9999 : top.value === "LOSS" ? -9999 : 0) * (state.turn === "T" ? 1 : -1) : 0);
document.getElementById("pv").textContent = "server analysis — see ranking";
} catch (e) {
src.textContent = "· local eval (server unreachable)";
analyzeLocal();
}
}
function renderMoves(view) {
const list = document.getElementById("moveList");
list.innerHTML = "";
if (!view.length) { list.innerHTML = '<div class="val" style="padding:6px 10px">No legal moves.</div>'; return; }
view.forEach((r, i) => {
const el = document.createElement("div");
el.className = "move" + (i === 0 ? " best" : "");
const valTxt = typeof r.val === "number"
? (r.tag ? (r.tag === "win" ? "mate" : "loss") : (r.val >= 0 ? "+" : "") + Math.round(r.val))
: r.val;
el.innerHTML = `<span class="mv">${r.mv}</span>` +
(r.tag ? `<span class="tag ${r.tag}">${r.tag}</span>` : `<span></span>`) +
`<span class="val">${valTxt}</span>`;
if (r.move) el.addEventListener("click", () => doMove(r.move));
list.appendChild(el);
});
}
// ---------- controls ----------
document.getElementById("btnEngine").addEventListener("click", () => {
if (winner(state)) return;
const depth = +document.getElementById("depth").value;
const rows = rankMoves(state, depth);
if (rows.length) {
const best = rows[0].val;
const ties = rows.filter(r => r.val === best);
doMove(ties[Math.floor(Math.random() * ties.length)].m);
}
});
document.getElementById("btnUndo").addEventListener("click", () => {
if (history.length) { state = history.pop(); lastMove = null; selected = null; destMap.clear(); analyze(); }
});
document.getElementById("btnNew").addEventListener("click", () => {
state = initial(); history = []; lastMove = null; selected = null; destMap.clear();
watchRunning = false; updateWatchBtn(); analyze();
if (gameMode === "play") setTimeout(maybeBotTurn, 150);
});
document.getElementById("depth").addEventListener("input", e => {
document.getElementById("depthVal").textContent = e.target.value; analyze();
});
document.getElementById("serverUrl").addEventListener("change", () => { analyze(); fetchBots(); });
document.getElementById("btnCopy").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(serialize(state, lastMove ? moveStr(lastMove) : null)); flash("btnCopy", "Copied"); } catch {}
});
document.getElementById("btnLoad").addEventListener("click", async () => {
try {
const txt = await navigator.clipboard.readText();
const s = parseObx(txt);
state = s; history = []; lastMove = null; selected = null; destMap.clear(); analyze(); flash("btnLoad", "Loaded");
} catch (e) { flash("btnLoad", "Bad OBX"); }
});
function flash(id, msg) { const b = document.getElementById(id); const t = b.textContent; b.textContent = msg; setTimeout(() => b.textContent = t, 1100); }
// theme toggle
const root = document.documentElement;
document.getElementById("themeToggle").addEventListener("click", () => {
const cur = root.getAttribute("data-theme")
|| (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
root.setAttribute("data-theme", cur === "dark" ? "light" : "dark");
});
// ---------- play vs bot / watch bots ----------
const LOCAL = "__local__";
let gameMode = "analysis"; // 'analysis' | 'play' | 'watch'
let userSide = "G"; // side the human plays in 'play' mode
let botThinking = false;
let watchRunning = false;
const $ = id => document.getElementById(id);
const serverBase = () => $("serverUrl").value.trim().replace(/\/$/, "");
function resolveMoveStr(s, text) { for (const m of legalMoves(s)) if (moveStr(m) === text) return m; return null; }
// Ask a bot for a move: the named server bot if a Bot API is set, else the local JS engine.
async function botChooseMove(botName) {
const base = serverBase();
if (base && botName && botName !== LOCAL) {
try {
const res = await fetch(base + "/move", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ obx: serialize(state), bot: botName, time_ms: 1000 })
});
if (res.ok) {
const data = await res.json();
if (data.move) { const m = resolveMoveStr(state, data.move); if (m) return m; }
}
} catch (e) { /* fall through to local engine */ }
}
// Local fallback: depth-limited negamax, random among ties.
const depth = +$("depth").value;
const rows = rankMoves(state, depth);
if (!rows.length) return null;
const best = rows[0].val, ties = rows.filter(r => r.val === best);
return ties[Math.floor(Math.random() * ties.length)].m;
}
async function doOneBotMove(botName) {
if (winner(state) || botThinking) return false;
botThinking = true; render();
const m = await botChooseMove(botName);
botThinking = false;
if (!m) return false;
doMove(m); // doMove -> render + analyze + scheduleAuto
return true;
}
// Called at the end of every move. Drives the opponent in 'play' mode.
function scheduleAuto() {
if (winner(state)) { if (gameMode === "watch") { watchRunning = false; updateWatchBtn(); } return; }
if (gameMode === "play") setTimeout(maybeBotTurn, 180);
}
async function maybeBotTurn() {
if (gameMode !== "play" || winner(state) || botThinking) return;
if (state.turn === userSide) { setPlayStatus("Your move."); return; }
setPlayStatus("Bot thinking…", true);
await doOneBotMove($("playBot").value);
if (!winner(state) && state.turn === userSide) setPlayStatus("Your move.");
}
async function watchLoop() {
if (!watchRunning) return;
const bot = state.turn === "T" ? $("tigerBot").value : $("goatBot").value;
const ok = await doOneBotMove(bot);
if (!ok || winner(state) || !watchRunning) { watchRunning = false; updateWatchBtn(); return; }
setTimeout(watchLoop, +$("watchSpeed").value);
}
function updateWatchBtn() {
const b = $("watchStart"); if (b) b.textContent = watchRunning ? "Pause" : "Start";
}
function setPlayStatus(msg, thinking) {
const el = $("playStatus");
el.innerHTML = thinking ? `<span class="think"><span class="spin"></span>${msg}</span>` : msg;
}
function setMode(m) {
gameMode = m; watchRunning = false; updateWatchBtn();
document.querySelectorAll("#modeRow [data-mode]").forEach(b =>
b.classList.toggle("primary", b.dataset.mode === m));
$("playCtl").style.display = m === "play" ? "block" : "none";
$("watchCtl").style.display = m === "watch" ? "block" : "none";
if (m === "play") setTimeout(maybeBotTurn, 150);
}
async function fetchBots() {
const selects = [$("playBot"), $("tigerBot"), $("goatBot")];
let opts = [{ name: LOCAL, title: "Local engine (in-browser)" }];
const base = serverBase();
if (base) {
try {
const r = await fetch(base + "/bots");
if (r.ok) { const d = await r.json(); opts = opts.concat(d.bots.map(b => ({ name: b.name, title: `${b.title} — ${b.algorithm}` }))); }
} catch (e) { /* server offline: keep Local only */ }
}
for (const sel of selects) {
const cur = sel.value;
sel.innerHTML = "";
for (const o of opts) { const el = document.createElement("option"); el.value = o.name; el.textContent = o.title; sel.appendChild(el); }
if (cur && opts.some(o => o.name === cur)) sel.value = cur;
}
// Default to the strongest available bot (last in the roster) for opponents.
const strongest = opts[opts.length - 1].name;
if (!$("playBot").value || $("playBot").value === LOCAL) $("playBot").value = strongest;
if (!$("tigerBot").value || $("tigerBot").value === LOCAL) $("tigerBot").value = strongest;
if (!$("goatBot").value || $("goatBot").value === LOCAL) $("goatBot").value = strongest;
}
function setSide(side) {
userSide = side;
$("sideGoat").classList.toggle("primary", side === "G");
$("sideTiger").classList.toggle("primary", side === "T");
setTimeout(maybeBotTurn, 120);
}
// wire mode controls
document.querySelectorAll("#modeRow [data-mode]").forEach(b =>
b.addEventListener("click", () => setMode(b.dataset.mode)));
$("sideGoat").addEventListener("click", () => setSide("G"));
$("sideTiger").addEventListener("click", () => setSide("T"));
$("watchStart").addEventListener("click", () => {
if (winner(state)) return;
watchRunning = !watchRunning; updateWatchBtn();
if (watchRunning) watchLoop();
});
$("watchStep").addEventListener("click", () => {
const bot = state.turn === "T" ? $("tigerBot").value : $("goatBot").value;
doOneBotMove(bot);
});
$("watchSpeed").addEventListener("input", e => { $("watchSpeedVal").textContent = e.target.value + "ms"; });
// boot
fetchBots();
analyze();
</script>
<script src="pyodide_boot.js"></script>