File size: 10,902 Bytes
a86c764
 
 
 
66073f0
 
 
 
 
 
 
a86c764
 
 
 
 
 
 
 
 
 
 
f454219
 
 
 
 
 
a86c764
 
 
66073f0
 
 
 
ea88929
a86c764
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea88929
 
a86c764
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea88929
 
 
a86c764
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea88929
a86c764
 
 
ea88929
a86c764
 
ea88929
 
a86c764
ea88929
a86c764
 
 
 
 
 
ea88929
a86c764
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// 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); }
})();