Spaces:
Running
Running
Download index.html from techaaditya/BaghchalAI: direct link, hf CLI and curl.
- Browser
- Download file 38.3 kB
-
https://huggingface.co/spaces/techaaditya/BaghchalAI/resolve/main/index.html
- Command line
-
hf download hf://spaces/techaaditya/BaghchalAI/index.html
-
curl -L -o index.html https://huggingface.co/spaces/techaaditya/BaghchalAI/resolve/main/index.html
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> · 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 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 API</b> URL to upgrade ranking to the exact WDL / trap engine.</div> | |
| </div> | |
| </aside> | |
| </div> | |
| </div> | |
| <script> | |
| ; | |
| // ---------- 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> | |