← Chess results

Chess

hy4 preview · full

Initial view of Chess

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 68,560 bytes · SHA-256 d14da51280c5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Chess — Grandmaster in a Box</title>
<style>
/* ============================================================
   TUNABLE CONSTANTS — visual palette & metrics
   ============================================================ */
:root{
  --sq: min(74px, 11.2vw, 8.6vh);           /* square size            */
  --light: #eddbb8;                          /* light square (warm)    */
  --dark:  #a9784f;                          /* dark square (walnut)   */
  --light-sel: #f2e08a;                      /* selected square        */
  --dark-sel:  #cb9a3f;
  --last-light:#e8dda6;                      /* last-move highlight    */
  --last-dark: #c9a86a;
  --check: #e0483a;                          /* check glow             */
  --frame: #4a3524;
  --frame2:#33241a;
  --bg: #1d1a17;
  --bg2:#272320;
  --panel:#2a2521;
  --panel2:#211d1a;
  --ink: #f0e6d8;
  --ink-dim:#a99a86;
  --accent:#d8a24a;
  --accent2:#8f6a2f;
  --line: rgba(255,255,255,.08);
  --white-piece:#fdf6e6;
  --black-piece:#20160f;
  --radius: 12px;
  --font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
  margin:0; background:
    radial-gradient(1200px 700px at 50% -10%, #352c25 0%, var(--bg) 55%, #14110f 100%);
  color:var(--ink); font-family:var(--font);
  display:flex; align-items:center; justify-content:center;
  padding:18px; -webkit-font-smoothing:antialiased;
  -webkit-tap-highlight-color:transparent;
}
.app{display:flex; flex-direction:column; gap:14px; align-items:center; max-width:1100px; width:100%}

/* ---------- header ---------- */
header{display:flex; align-items:baseline; gap:14px; width:100%; padding:0 4px}
h1{
  margin:0; font-size:20px; font-weight:600; letter-spacing:.14em; text-transform:uppercase;
  color:var(--ink);
}
h1 span{color:var(--accent)}
header .sub{font-size:12px; color:var(--ink-dim); letter-spacing:.06em}

/* ---------- layout ---------- */
main{display:flex; gap:26px; align-items:flex-start; justify-content:center; width:100%; flex-wrap:wrap}

/* ---------- board ---------- */
.board-frame{
  background:linear-gradient(160deg, var(--frame) 0%, var(--frame2) 100%);
  padding:12px; border-radius:var(--radius);
  box-shadow:0 26px 50px -18px rgba(0,0,0,.85), inset 0 1px 0 rgba(255,255,255,.09);
  display:flex; gap:6px;
}
.ranks,.files{display:flex; color:var(--ink-dim); font-size:11px; font-weight:600; letter-spacing:.05em}
.ranks{flex-direction:column; width:14px}
.ranks div,.files div{flex:1; display:flex; align-items:center; justify-content:center}
.board-col{display:flex; flex-direction:column; gap:6px}
.files{height:14px}
#board{
  display:grid; grid-template-columns:repeat(8, var(--sq)); grid-template-rows:repeat(8, var(--sq));
  border-radius:4px; overflow:hidden; position:relative;
  box-shadow:inset 0 0 0 1px rgba(0,0,0,.45);
  touch-action:none; user-select:none; -webkit-user-select:none;
}
.sq{position:relative; display:flex; align-items:center; justify-content:center; cursor:default}
.sq.light{background:var(--light)}
.sq.dark{background:var(--dark)}
.sq.last.light{background:var(--last-light)}
.sq.last.dark{background:var(--last-dark)}
.sq.sel.light{background:var(--light-sel)}
.sq.sel.dark{background:var(--dark-sel)}
.sq.hint::after{
  content:''; position:absolute; width:26%; height:26%; border-radius:50%;
  background:rgba(20,14,8,.34); box-shadow:inset 0 0 0 1px rgba(0,0,0,.25);
}
.sq.hint.cap::after{
  width:88%; height:88%; background:transparent; border-radius:50%;
  box-shadow:inset 0 0 0 5px rgba(30,18,8,.42);
}
.sq.hint{cursor:pointer}
.sq.movable{cursor:pointer}
.sq.check{background:radial-gradient(circle at 50% 50%, #f4796a 0%, #d43a2a 62%, #b92c1e 100%)}
.sq.check .piece{filter:drop-shadow(0 0 6px rgba(0,0,0,.6))}
.piece{
  font-size:calc(var(--sq) * .80); line-height:1; position:relative; z-index:2;
  pointer-events:none; transition:transform .12s ease;
  font-family:"DejaVu Sans","Segoe UI Symbol","Noto Sans Symbols2","Apple Symbols",serif;
}
.piece.w{color:var(--white-piece);
  text-shadow:0 1px 0 #6b563a, 0 2px 3px rgba(0,0,0,.45), 0 0 1px #4a3a26;
  -webkit-text-stroke:.02em #5a4527;
}
.piece.b{color:var(--black-piece);
  text-shadow:0 1px 1px rgba(255,255,255,.20), 0 2px 3px rgba(0,0,0,.4);
}
.sq.drag-src .piece{opacity:.28}
.piece.ghost{
  position:fixed; z-index:999; pointer-events:none; font-size:calc(var(--sq) * .92);
  transform:translate(-50%,-50%) scale(1.06);
  filter:drop-shadow(0 8px 12px rgba(0,0,0,.55));
}

/* ---------- side panel ---------- */
.panel{
  width:320px; min-width:280px; display:flex; flex-direction:column; gap:12px;
}
.card{
  background:linear-gradient(180deg, var(--panel) 0%, var(--panel2) 100%);
  border:1px solid var(--line); border-radius:var(--radius); padding:12px 14px;
  box-shadow:0 12px 26px -14px rgba(0,0,0,.8);
}
.card h2{
  margin:0 0 9px; font-size:10px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--ink-dim); font-weight:700; display:flex; justify-content:space-between; align-items:center;
}
/* turn indicator */
.turn{display:flex; align-items:center; gap:11px}
.turn .disc{
  width:22px; height:22px; border-radius:50%; flex:0 0 auto;
  background:radial-gradient(circle at 34% 30%, #fffdf6 0%, #e2d6bd 55%, #b6a68a 100%);
  box-shadow:0 2px 5px rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.25);
}
.turn .disc.b{background:radial-gradient(circle at 34% 30%, #4a3c30 0%, #241a12 60%, #120c08 100%)}
.turn .txt{font-size:15px; font-weight:600}
.turn .txt small{display:block; font-size:11px; font-weight:400; color:var(--ink-dim); letter-spacing:.04em}
.think{color:var(--accent); font-size:11px; letter-spacing:.06em}

/* trays */
.tray{display:flex; align-items:center; gap:4px; min-height:30px; flex-wrap:wrap}
.tray .lbl{font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-dim); width:52px; flex:0 0 auto}
.tray .p{font-size:22px; line-height:1}
.tray .p.w{color:var(--white-piece); -webkit-text-stroke:.02em #5a4527; text-shadow:0 1px 2px rgba(0,0,0,.5)}
.tray .p.b{color:#120c08; text-shadow:0 1px 0 rgba(255,255,255,.13)}
.tray .score{margin-left:auto; font-size:12px; color:var(--accent); font-variant-numeric:tabular-nums}

/* move list */
#moves{
  height:212px; overflow-y:auto; font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
  font-size:12.5px; line-height:1.75; padding-right:6px; scrollbar-width:thin;
  scrollbar-color:var(--accent2) transparent;
}
#moves::-webkit-scrollbar{width:7px}
#moves::-webkit-scrollbar-thumb{background:var(--accent2); border-radius:4px}
#moves table{width:100%; border-collapse:collapse}
#moves td{padding:1px 0}
#moves td.n{color:var(--ink-dim); width:30px; text-align:right; padding-right:10px; user-select:none}
#moves td.m{width:44%}
#moves td.m span{
  display:inline-block; padding:0 6px; border-radius:4px; cursor:default;
}
#moves tr:last-child td.m span.cur{background:rgba(216,162,74,.22); color:#ffe9bd}
#moves .empty{color:var(--ink-dim); font-style:italic; font-family:var(--font)}

/* controls */
.row{display:flex; gap:8px; flex-wrap:wrap}
button{
  font-family:var(--font); font-size:12px; font-weight:600; letter-spacing:.05em;
  color:var(--ink); background:linear-gradient(180deg,#3a332c,#2b251f);
  border:1px solid rgba(255,255,255,.12); border-radius:8px; padding:8px 12px; cursor:pointer;
  transition:transform .08s, background .15s, border-color .15s;
}
button:hover{background:linear-gradient(180deg,#463d34,#332c25); border-color:rgba(216,162,74,.5)}
button:active{transform:translateY(1px)}
button:disabled{opacity:.38; cursor:not-allowed; transform:none}
button.primary{background:linear-gradient(180deg,#c9963f,#9c6f28); color:#241a0c; border-color:#e0b566}
button.primary:hover{background:linear-gradient(180deg,#dcaa4c,#ac7c2d)}
.seg{display:inline-flex; border:1px solid rgba(255,255,255,.12); border-radius:8px; overflow:hidden}
.seg button{border:0; border-radius:0; background:transparent; padding:7px 11px}
.seg button + button{border-left:1px solid rgba(255,255,255,.12)}
.seg button.on{background:linear-gradient(180deg,#c9963f,#9c6f28); color:#241a0c}
.field{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:9px}
.field:last-child{margin-bottom:0}
.field > span{font-size:12px; color:var(--ink-dim); letter-spacing:.05em}
.hintline{font-size:11px; color:var(--ink-dim); line-height:1.6; margin-top:4px}
kbd{background:#1b1714; border:1px solid rgba(255,255,255,.14); border-bottom-width:2px; border-radius:4px; padding:0 4px; font-size:10px; font-family:inherit}

/* ---------- promotion picker ---------- */
#promo{
  position:fixed; z-index:500; display:flex; gap:2px; padding:4px; border-radius:8px;
  background:linear-gradient(180deg,#3b332b,#241d18); border:1px solid rgba(255,255,255,.16);
  box-shadow:0 14px 30px -8px rgba(0,0,0,.8);
}
#promo.vert{flex-direction:column}
#promo button{
  width:calc(var(--sq) * .92); height:calc(var(--sq) * .92); padding:0; border:0; border-radius:6px;
  background:var(--light); font-size:calc(var(--sq) * .62); line-height:1; display:flex;
  align-items:center; justify-content:center; color:var(--white-piece);
  -webkit-text-stroke:.02em #5a4527; text-shadow:0 1px 2px rgba(0,0,0,.4);
}
#promo button:hover{background:var(--light-sel); transform:none}
#promo button .pb{color:var(--black-piece); -webkit-text-stroke:0; text-shadow:0 1px 0 rgba(255,255,255,.15)}

/* ---------- game over banner ---------- */
#banner{
  position:fixed; inset:0; z-index:600; display:flex; align-items:center; justify-content:center;
  background:rgba(12,9,7,.62); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px);
  animation:fade .25s ease;
}
@keyframes fade{from{opacity:0}to{opacity:1}}
#banner .box{
  background:linear-gradient(180deg,#332b24,#221c17); border:1px solid rgba(216,162,74,.35);
  border-radius:16px; padding:26px 34px; text-align:center; min-width:280px;
  box-shadow:0 30px 60px -20px #000; animation:pop .28s cubic-bezier(.2,1.4,.4,1);
}
@keyframes pop{from{transform:scale(.9); opacity:0}to{transform:scale(1); opacity:1}}
#banner .res{font-size:24px; font-weight:700; letter-spacing:.04em; margin-bottom:6px}
#banner .why{font-size:13px; color:var(--ink-dim); margin-bottom:18px}
.hidden{display:none !important}

@media (max-width: 860px){
  main{gap:16px}
  .panel{width:100%; max-width:520px}
  #moves{height:150px}
  body{padding:10px; align-items:flex-start}
}
</style>
</head>
<body>
<div class="app">
  <header>
    <h1>Chess <span>&middot;</span> Grandmaster in a Box</h1>
    <div class="sub">full rules &middot; alpha-beta engine</div>
  </header>

  <main>
    <!-- ================= BOARD ================= -->
    <div class="board-frame">
      <div class="ranks" id="ranks"></div>
      <div class="board-col">
        <div id="board"></div>
        <div class="files" id="files"></div>
      </div>
    </div>

    <!-- ================= SIDE PANEL ================= -->
    <aside class="panel">
      <div class="card">
        <div class="turn">
          <div class="disc" id="turnDisc"></div>
          <div class="txt" id="turnTxt">White to move<small>&nbsp;</small></div>
        </div>
      </div>

      <div class="card">
        <h2>Captured</h2>
        <div class="tray"><span class="lbl">White</span><span id="trayW"></span><span class="score" id="scoreW"></span></div>
        <div class="tray" style="margin-top:6px"><span class="lbl">Black</span><span id="trayB"></span><span class="score" id="scoreB"></span></div>
      </div>

      <div class="card">
        <h2>Moves <span id="plyCount" style="letter-spacing:.04em"></span></h2>
        <div id="moves"><div class="empty">No moves yet.</div></div>
      </div>

      <div class="card">
        <h2>Game</h2>
        <div class="field">
          <span>Play as</span>
          <span class="seg" id="segColor">
            <button data-v="w" class="on">White</button>
            <button data-v="b">Black</button>
            <button data-v="both">Both</button>
          </span>
        </div>
        <div class="field">
          <span>Strength</span>
          <span class="seg" id="segDepth">
            <button data-v="1">Depth 1</button>
            <button data-v="2">Depth 2</button>
            <button data-v="3" class="on">Depth 3</button>
          </span>
        </div>
        <div class="row" style="margin-top:12px">
          <button id="btnNew" class="primary">New Game</button>
          <button id="btnUndo">Undo</button>
          <button id="btnFlip">Flip</button>
          <button id="btnMute">Sound: on</button>
        </div>
        <div class="hintline">Click a piece then a square, or drag &amp; drop. <kbd>U</kbd> undo &middot; <kbd>F</kbd> flip &middot; <kbd>N</kbd> new</div>
      </div>
    </aside>
  </main>
</div>

<div id="promo" class="hidden"></div>
<div id="banner" class="hidden">
  <div class="box">
    <div class="res" id="bannerRes">Checkmate</div>
    <div class="why" id="bannerWhy"></div>
    <div class="row" style="justify-content:center">
      <button class="primary" id="bannerNew">New Game</button>
      <button id="bannerClose">Review board</button>
    </div>
  </div>
</div>

<script>
"use strict";
/* ==========================================================================
   CHESS — single file, no dependencies.
   Sections:
     1. TUNABLES
     2. BOARD REPRESENTATION (0x88) + FEN
     3. MOVE GENERATION (all special rules)
     4. MAKE / UNMAKE
     5. GAME STATUS (check / mate / draws)
     6. SAN + NOTATION
     7. EVALUATION (material + piece-square tables)
     8. SEARCH (negamax + alpha-beta + quiescence, iterative deepening)
     9. AUDIO
    10. PERSISTENCE
    11. RENDERING + INTERACTION
    12. DEBUG INTERFACE (window.game)
   ========================================================================== */

/* ==========================================================================
   1. TUNABLES
   ========================================================================== */
const CONFIG = {
  /* --- AI --- */
  AI_DEPTHS:      [1, 2, 3],      // difficulty selector options
  AI_DEFAULT:     3,              // default depth
  AI_TIME_BUDGET: 1100,           // ms — hard-ish cap for a whole AI move
  AI_YIELD_MS:    45,             // ms between UI yields while searching
  QUIESCE_MAX:    6,              // quiescence extra plies

  /* --- evaluation weights --- */
  MATERIAL: { p:100, n:320, b:330, r:500, q:900, k:20000 },
  PST_WEIGHT: 1.0,
  BISHOP_PAIR: 30,
  MOBILITY_W: 0,                  // 0 = off (kept for tuning)
  MATE: 100000,

  /* --- rules --- */
  FIFTY_MOVE_DRAW: 100,           // half-moves (50 full moves)

  /* --- misc --- */
  STORAGE_KEY: 'chess.grandmaster.v1',
  AI_DELAY: 220                   // ms pause before AI "starts thinking"
};

/* ==========================================================================
   2. BOARD REPRESENTATION (0x88)
   ========================================================================== */
/*  index = rank*16 + file   (rank 0 = rank "1", file 0 = file "a")
    off-board detection: (index & 0x88) !== 0                                */

const FILES = ['a','b','c','d','e','f','g','h'];
const RANKS = ['1','2','3','4','5','6','7','8'];

/** SQUARES['e4'] -> 0x88 index ; ALGEBRAIC[0x88] -> 'e4' (or undefined) */
const SQUARES = {};
const ALGEBRAIC = new Array(128);
for (let r = 0; r < 8; r++) {
  for (let f = 0; f < 8; f++) {
    const idx = r * 16 + f;
    const name = FILES[f] + RANKS[r];
    SQUARES[name] = idx;
    ALGEBRAIC[idx] = name;
  }
}
const OFF = (i) => (i & 0x88) !== 0;
const fileOf = (i) => i & 15;
const rankOf = (i) => i >> 4;

/* piece codes: uppercase = white, lowercase = black */
const PAWN='p', KNIGHT='n', BISHOP='b', ROOK='r', QUEEN='q', KING='k';
const colorOf = (p) => (p && p === p.toUpperCase()) ? 'w' : 'b';
const typeOf  = (p) => (p ? p.toLowerCase() : null);
const swapColor = (c) => (c === 'w' ? 'b' : 'w');

const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';

/* --- move flags (bitmask) --- */
const FLAGS = {
  NORMAL:1, CAPTURE:2, BIG_PAWN:4, EP_CAPTURE:8,
  PROMOTION:16, KSIDE_CASTLE:32, QSIDE_CASTLE:64
};

/* --- piece offsets --- */
const PAWN_DIRS = { w: 16, b: -16 };                 // single push
const PAWN_ATTACKS = { w: [15, 17], b: [-15, -17] };  // (0x88: +15 = up-left, +17 = up-right)
const PIECE_DIRS = {
  n: [ 33, 18, -14, -31, -33, -18,  14,  31],
  b: [ 17,  15, -17, -15],
  r: [ 16, -16,   1,  -1],
  q: [ 17,  16,  15,   1,  -1, -15, -16, -17],
  k: [ 17,  16,  15,   1,  -1, -15, -16, -17]
};
const SLIDING = { b:true, r:true, q:true };

/* --- castling bookkeeping --- */
const CASTLE_INFO = {
  w: { k:{ king:SQUARES.e1, rook:SQUARES.h1, kingTo:SQUARES.g1, rookTo:SQUARES.f1,
           empties:[SQUARES.f1, SQUARES.g1], safe:[SQUARES.e1, SQUARES.f1, SQUARES.g1], piece:'R' },
       q:{ king:SQUARES.e1, rook:SQUARES.a1, kingTo:SQUARES.c1, rookTo:SQUARES.d1,
           empties:[SQUARES.d1, SQUARES.c1, SQUARES.b1], safe:[SQUARES.e1, SQUARES.d1, SQUARES.c1], piece:'R' } },
  b: { k:{ king:SQUARES.e8, rook:SQUARES.h8, kingTo:SQUARES.g8, rookTo:SQUARES.f8,
           empties:[SQUARES.f8, SQUARES.g8], safe:[SQUARES.e8, SQUARES.f8, SQUARES.g8], piece:'r' },
       q:{ king:SQUARES.e8, rook:SQUARES.a8, kingTo:SQUARES.c8, rookTo:SQUARES.d8,
           empties:[SQUARES.d8, SQUARES.c8, SQUARES.b8], safe:[SQUARES.e8, SQUARES.d8, SQUARES.c8], piece:'r' } }
};

/* ------------------------- state ------------------------- */
/** Creates an empty game state object. */
function emptyState() {
  return {
    board: new Array(128).fill(null),
    turn: 'w',
    castling: { w:{ k:false, q:false }, b:{ k:false, q:false } },
    ep: -1,
    halfmoves: 0,
    fullmove: 1,
    kings: { w:-1, b:-1 }
  };
}

/** Parses a FEN string into a state object. */
function loadFen(fen) {
  const st = emptyState();
  const parts = fen.trim().split(/\s+/);
  if (parts.length < 4) throw new Error('bad FEN: ' + fen);
  const rows = parts[0].split('/');
  if (rows.length !== 8) throw new Error('bad FEN board: ' + fen);
  for (let i = 0; i < 8; i++) {
    const rank = 7 - i;                       // rows[0] is rank 8
    let f = 0;
    for (const ch of rows[i]) {
      if (/[1-8]/.test(ch)) { f += +ch; }
      else {
        if (f > 7) throw new Error('bad FEN row: ' + rows[i]);
        const idx = rank * 16 + f;
        st.board[idx] = ch;
        if (ch === 'K') st.kings.w = idx;
        if (ch === 'k') st.kings.b = idx;
        f++;
      }
    }
  }
  st.turn = (parts[1] === 'b') ? 'b' : 'w';
  const c = parts[2];
  st.castling.w.k = c.includes('K');
  st.castling.w.q = c.includes('Q');
  st.castling.b.k = c.includes('k');
  st.castling.b.q = c.includes('q');
  st.ep = (parts[3] && parts[3] !== '-' && SQUARES[parts[3]] !== undefined) ? SQUARES[parts[3]] : -1;
  st.halfmoves = parts[4] !== undefined ? (+parts[4] || 0) : 0;
  st.fullmove  = parts[5] !== undefined ? (+parts[5] || 1) : 1;
  return st;
}

/** Serializes a state object to FEN. */
function toFen(st) {
  let out = '';
  for (let rank = 7; rank >= 0; rank--) {
    let empty = 0;
    for (let f = 0; f < 8; f++) {
      const p = st.board[rank * 16 + f];
      if (!p) { empty++; }
      else {
        if (empty) { out += empty; empty = 0; }
        out += p;
      }
    }
    if (empty) out += empty;
    if (rank) out += '/';
  }
  let c = '';
  if (st.castling.w.k) c += 'K';
  if (st.castling.w.q) c += 'Q';
  if (st.castling.b.k) c += 'k';
  if (st.castling.b.q) c += 'q';
  if (!c) c = '-';
  return [out, st.turn, c, (st.ep >= 0 ? ALGEBRAIC[st.ep] : '-'), st.halfmoves, st.fullmove].join(' ');
}

/** Deep-ish copy of a state (board array + scalars). */
function cloneState(st) {
  return {
    board: st.board.slice(),
    turn: st.turn,
    castling: { w:{ k:st.castling.w.k, q:st.castling.w.q }, b:{ k:st.castling.b.k, q:st.castling.b.q } },
    ep: st.ep,
    halfmoves: st.halfmoves,
    fullmove: st.fullmove,
    kings: { w:st.kings.w, b:st.kings.b }
  };
}

/* ==========================================================================
   3. MOVE GENERATION
   ========================================================================== */
/** Is `square` attacked by any piece of colour `byColor`? */
function isAttacked(board, square, byColor) {
  const up = byColor === 'w';
  /* pawns: a pawn on `square` attacks diagonally "forward"; so look backwards */
  for (const d of PAWN_ATTACKS[byColor]) {
    const from = square - d;                 // inverse direction
    if (OFF(from)) continue;
    const p = board[from];
    if (p && p.toLowerCase() === PAWN && colorOf(p) === byColor) return true;
  }
  /* knights */
  for (const d of PIECE_DIRS.n) {
    const from = square + d;
    if (OFF(from)) continue;
    const p = board[from];
    if (p && p.toLowerCase() === KNIGHT && colorOf(p) === byColor) return true;
  }
  /* king */
  for (const d of PIECE_DIRS.k) {
    const from = square + d;
    if (OFF(from)) continue;
    const p = board[from];
    if (p && p.toLowerCase() === KING && colorOf(p) === byColor) return true;
  }
  /* sliding: rook/queen */
  for (const d of PIECE_DIRS.r) {
    let s = square + d;
    while (!OFF(s)) {
      const p = board[s];
      if (p) {
        if (colorOf(p) === byColor) {
          const t = p.toLowerCase();
          if (t === ROOK || t === QUEEN) return true;
        }
        break;
      }
      s += d;
    }
  }
  /* sliding: bishop/queen */
  for (const d of PIECE_DIRS.b) {
    let s = square + d;
    while (!OFF(s)) {
      const p = board[s];
      if (p) {
        if (colorOf(p) === byColor) {
          const t = p.toLowerCase();
          if (t === BISHOP || t === QUEEN) return true;
        }
        break;
      }
      s += d;
    }
  }
  return false;
}

/** Is the side-to-move's king currently in check? */
function inCheck(st) {
  const k = st.kings[st.turn];
  if (k < 0) return false;
  return isAttacked(st.board, k, swapColor(st.turn));
}

/** Pushes a pawn move (handles double push, promotion, en passant). */
function addPawnMove(st, moves, from, to, flags) {
  const board = st.board, us = st.turn;
  const lastRank = us === 'w' ? 7 : 0;
  if (rankOf(to) === lastRank) {
    for (const promo of [QUEEN, ROOK, BISHOP, KNIGHT]) {
      const m = { from, to, piece: board[from], capture: null, promotion: promo,
                  flags: flags | FLAGS.PROMOTION };
      if (flags & FLAGS.CAPTURE) m.capture = board[to];
      moves.push(m);
    }
  } else {
    const m = { from, to, piece: board[from], capture: null, promotion: null, flags };
    if (flags & FLAGS.CAPTURE) m.capture = board[to];
    moves.push(m);
  }
}

/**
 * Pseudo-legal move generation.
 * @param {object} st state
 * @param {object} [opts] { square: only generate from this 0x88 index,
 *                          capturesOnly: only captures/promotions }
 */
function generateMoves(st, opts) {
  opts = opts || {};
  const moves = [];
  const board = st.board, us = st.turn, them = swapColor(us);
  const onlySq = (opts.square !== undefined && opts.square !== null) ? opts.square : -1;
  const capsOnly = !!opts.capturesOnly;

  const start = (onlySq >= 0) ? onlySq : 0;
  const end   = (onlySq >= 0) ? onlySq : 119;

  for (let from = start; from <= end; from++) {
    if (OFF(from)) continue;
    const piece = board[from];
    if (!piece || colorOf(piece) !== us) continue;
    const type = piece.toLowerCase();

    if (type === PAWN) {
      const dir = PAWN_DIRS[us];
      const startRank = us === 'w' ? 1 : 6;
      const one = from + dir;
      if (!OFF(one) && !board[one] && !capsOnly) {
        addPawnMove(st, moves, from, one, FLAGS.NORMAL);
        const two = from + 2 * dir;
        if (rankOf(from) === startRank && !board[two]) {
          moves.push({ from, to: two, piece, capture: null, promotion: null,
                       flags: FLAGS.BIG_PAWN });
        }
      }
      for (const d of PAWN_ATTACKS[us]) {
        const to = from + d;
        if (OFF(to)) continue;
        const target = board[to];
        if (target && colorOf(target) === them) {
          addPawnMove(st, moves, from, to, FLAGS.CAPTURE);
        } else if (!target && to === st.ep) {
          moves.push({ from, to, piece, capture: us === 'w' ? 'p' : 'P', promotion: null,
                       flags: FLAGS.EP_CAPTURE | FLAGS.CAPTURE });
        }
      }
      continue;
    }

    if (type === KING) {
      for (const d of PIECE_DIRS.k) {
        const to = from + d;
        if (OFF(to)) continue;
        const target = board[to];
        if (target && colorOf(target) === us) continue;
        if (capsOnly && !target) continue;
        moves.push({ from, to, piece, capture: target || null, promotion: null,
                     flags: target ? FLAGS.CAPTURE : FLAGS.NORMAL });
      }
      /* castling */
      if (!capsOnly && from === CASTLE_INFO[us].k.king && !isAttacked(board, from, them)) {
        for (const side of ['k','q']) {
          const info = CASTLE_INFO[us][side];
          if (!st.castling[us][side]) continue;
          if (board[info.rook] !== info.piece) continue;
          let ok = true;
          for (const s of info.empties) { if (board[s]) { ok = false; break; } }
          if (!ok) continue;
          for (const s of info.safe) { if (isAttacked(board, s, them)) { ok = false; break; } }
          if (!ok) continue;
          moves.push({ from, to: info.kingTo, piece, capture: null, promotion: null,
                       flags: (side === 'k' ? FLAGS.KSIDE_CASTLE : FLAGS.QSIDE_CASTLE) });
        }
      }
      continue;
    }

    /* knights, bishops, rooks, queens */
    const dirs = PIECE_DIRS[type];
    if (!dirs) continue;
    for (const d of dirs) {
      let to = from + d;
      while (!OFF(to)) {
        const target = board[to];
        if (!target) {
          if (!capsOnly) moves.push({ from, to, piece, capture: null, promotion: null, flags: FLAGS.NORMAL });
        } else {
          if (colorOf(target) === them) {
            moves.push({ from, to, piece, capture: target, promotion: null, flags: FLAGS.CAPTURE });
          }
          break;
        }
        if (!SLIDING[type]) break;
        to += d;
      }
    }
  }
  return moves;
}

/**
 * Legal move generation — pseudo-legal filtered by "does my king end up safe?".
 * This is the single source of truth for the UI: illegal states are unreachable.
 */
function legalMoves(st, opts) {
  const pseudo = generateMoves(st, opts);
  const out = [];
  const us = st.turn;
  for (const m of pseudo) {
    const undo = makeMove(st, m);
    if (!isAttacked(st.board, st.kings[us], swapColor(us))) out.push(m);
    unmakeMove(st, undo);
  }
  return out;
}

/* ==========================================================================
   4. MAKE / UNMAKE
   ========================================================================== */
/** Applies `move` to `st`, returning an undo record. Mutating (fast for search). */
function makeMove(st, m) {
  const board = st.board;
  const us = st.turn, them = swapColor(us);
  const undo = {
    m,
    castling: { w:{ k:st.castling.w.k, q:st.castling.w.q }, b:{ k:st.castling.b.k, q:st.castling.b.q } },
    ep: st.ep,
    halfmoves: st.halfmoves,
    fullmove: st.fullmove,
    kings: { w:st.kings.w, b:st.kings.b }
  };

  board[m.to] = board[m.from];
  board[m.from] = null;

  /* en-passant capture removes the pawn *behind* the target square */
  if (m.flags & FLAGS.EP_CAPTURE) {
    board[m.to + (us === 'w' ? -16 : 16)] = null;
  }

  /* promotion */
  if (m.flags & FLAGS.PROMOTION) {
    board[m.to] = (us === 'w') ? m.promotion.toUpperCase() : m.promotion;
  }

  /* king tracking */
  if (m.piece.toLowerCase() === KING) st.kings[us] = m.to;

  /* castling rights */
  if (m.from === SQUARES.e1 || m.to === SQUARES.e1) { st.castling.w.k = false; st.castling.w.q = false; }
  if (m.from === SQUARES.e8 || m.to === SQUARES.e8) { st.castling.b.k = false; st.castling.b.q = false; }
  if (m.from === SQUARES.h1 || m.to === SQUARES.h1) st.castling.w.k = false;
  if (m.from === SQUARES.a1 || m.to === SQUARES.a1) st.castling.w.q = false;
  if (m.from === SQUARES.h8 || m.to === SQUARES.h8) st.castling.b.k = false;
  if (m.from === SQUARES.a8 || m.to === SQUARES.a8) st.castling.b.q = false;

  /* rook hop for castling */
  if (m.flags & FLAGS.KSIDE_CASTLE) {
    const info = CASTLE_INFO[us].k;
    board[info.rookTo] = board[info.rook];
    board[info.rook] = null;
  } else if (m.flags & FLAGS.QSIDE_CASTLE) {
    const info = CASTLE_INFO[us].q;
    board[info.rookTo] = board[info.rook];
    board[info.rook] = null;
  }

  /* en-passant target square (only after a double push) */
  st.ep = (m.flags & FLAGS.BIG_PAWN) ? (m.from + PAWN_DIRS[us]) : -1;

  /* clocks */
  if ((m.flags & FLAGS.CAPTURE) || m.piece.toLowerCase() === PAWN) st.halfmoves = 0;
  else st.halfmoves++;
  if (us === 'b') st.fullmove++;
  st.turn = them;
  return undo;
}

/** Reverses `makeMove`. */
function unmakeMove(st, undo) {
  const board = st.board;
  const m = undo.m;
  const us = swapColor(st.turn);      // side that made the move

  st.turn = us;
  st.castling = undo.castling;
  st.ep = undo.ep;
  st.halfmoves = undo.halfmoves;
  st.fullmove = undo.fullmove;
  st.kings = undo.kings;

  board[m.from] = m.piece;
  board[m.to] = null;

  if (m.flags & FLAGS.EP_CAPTURE) {
    board[m.to + (us === 'w' ? -16 : 16)] = (us === 'w' ? 'p' : 'P');
  }
  if (m.flags & FLAGS.CAPTURE && !(m.flags & FLAGS.EP_CAPTURE)) {
    board[m.to] = m.capture;
  }
  if (m.flags & FLAGS.KSIDE_CASTLE) {
    const info = CASTLE_INFO[us].k;
    board[info.rook] = board[info.rookTo];
    board[info.rookTo] = null;
  } else if (m.flags & FLAGS.QSIDE_CASTLE) {
    const info = CASTLE_INFO[us].q;
    board[info.rook] = board[info.rookTo];
    board[info.rookTo] = null;
  }
}

/** UCI string for a move, e.g. "e2e4", "e7e8q". */
function moveToUci(m) {
  return ALGEBRAIC[m.from] + ALGEBRAIC[m.to] + (m.promotion ? m.promotion : '');
}

/** Finds the legal move matching a UCI string, or null. */
function findMoveByUci(st, uci) {
  if (typeof uci !== 'string' || uci.length < 4) return null;
  const from = SQUARES[uci.slice(0, 2)];
  const to   = SQUARES[uci.slice(2, 4)];
  const promo = uci.length > 4 ? uci[4].toLowerCase() : null;
  if (from === undefined || to === undefined) return null;
  for (const m of legalMoves(st)) {
    if (m.from === from && m.to === to) {
      if (m.promotion) { if (m.promotion === promo) return m; }
      else if (!promo) return m;
    }
  }
  return null;
}

/* ==========================================================================
   5. GAME STATUS — check, mate, stalemate, draws
   ========================================================================== */
const squareColor = (sq) => (fileOf(sq) + rankOf(sq)) & 1;

/** K vs K, K+B vs K, K+N vs K (and same-coloured bishops) — no one can mate. */
function insufficientMaterial(st) {
  const counts = { p:0, n:0, b:0, r:0, q:0 };
  const bishopSquares = [];
  for (let r = 0; r < 8; r++) {
    for (let f = 0; f < 8; f++) {
      const p = st.board[r * 16 + f];
      if (!p) continue;
      const t = p.toLowerCase();
      if (t === KING) continue;
      counts[t]++;
      if (t === BISHOP) bishopSquares.push(r * 16 + f);
    }
  }
  if (counts.p || counts.r || counts.q) return false;      // mating material exists
  const minors = counts.n + counts.b;
  if (minors === 0) return true;                            // K vs K
  if (minors === 1) return true;                            // K+minor vs K
  if (counts.b === 2 && counts.n === 0 &&
      squareColor(bishopSquares[0]) === squareColor(bishopSquares[1])) return true;
  return false;
}

/**
 * Full status of a position.
 * @returns {{over:boolean, result:string|null, winner:string|null,
 *            check:boolean, moves:Array, reason:string}}
 */
function gameStatus(st) {
  const check = inCheck(st);
  const moves = legalMoves(st);
  if (moves.length === 0) {
    return { over:true, check, moves,
      result: check ? 'checkmate' : 'stalemate',
      winner: check ? swapColor(st.turn) : null,
      reason: check ? 'Checkmate' : 'Stalemate' };
  }
  if (insufficientMaterial(st)) {
    return { over:true, check, moves, result:'draw', winner:null,
             reason:'Draw — insufficient material' };
  }
  if (st.halfmoves >= CONFIG.FIFTY_MOVE_DRAW) {
    return { over:true, check, moves, result:'draw', winner:null,
             reason:'Draw — fifty-move rule' };
  }
  return { over:false, check, moves, result:null, winner:null, reason:'' };
}

/* ==========================================================================
   6. SAN / NOTATION
   ========================================================================== */
/** Builds the disambiguation prefix ("" | "e" | "4" | "e4") for a piece move. */
function disambiguate(st, m, legal) {
  if (!legal) return '';
  const rivals = legal.filter(o =>
    o !== m && o.to === m.to && o.piece === m.piece && o.from !== m.from);
  if (!rivals.length) return '';
  const sameFile = rivals.some(o => fileOf(o.from) === fileOf(m.from));
  const sameRank = rivals.some(o => rankOf(o.from) === rankOf(m.from));
  if (!sameFile) return FILES[fileOf(m.from)];
  if (!sameRank) return RANKS[rankOf(m.from)];
  return FILES[fileOf(m.from)] + RANKS[rankOf(m.from)];
}

/**
 * Standard Algebraic Notation for `m` played in `st`.
 * `legal` (optional) is the legal-move list of the position, for disambiguation.
 */
function moveToSan(st, m, legal) {
  let san;
  if (m.flags & FLAGS.KSIDE_CASTLE)      san = 'O-O';
  else if (m.flags & FLAGS.QSIDE_CASTLE) san = 'O-O-O';
  else {
    const type = m.piece.toLowerCase();
    const to = ALGEBRAIC[m.to];
    if (type === PAWN) {
      if (m.flags & FLAGS.CAPTURE) san = FILES[fileOf(m.from)] + 'x' + to;
      else san = to;
      if (m.flags & FLAGS.PROMOTION) san += '=' + m.promotion.toUpperCase();
      if (m.flags & FLAGS.EP_CAPTURE) san += ' e.p.';
    } else {
      san = type.toUpperCase() + disambiguate(st, m, legal) +
            ((m.flags & FLAGS.CAPTURE) ? 'x' : '') + to;
    }
  }
  /* suffix: + or # */
  const undo = makeMove(st, m);
  let suffix = '';
  if (inCheck(st)) suffix = legalMoves(st).length === 0 ? '#' : '+';
  unmakeMove(st, undo);
  return san + suffix;
}

/* ==========================================================================
   7. EVALUATION — material + piece-square tables
   ========================================================================== */
/* Tables are written rank 8 → rank 1, file a → h (index 0 = a8). */
const PST = {
  p: [
     0,  0,  0,  0,  0,  0,  0,  0,
    50, 50, 50, 50, 50, 50, 50, 50,
    10, 10, 20, 30, 30, 20, 10, 10,
     5,  5, 10, 25, 25, 10,  5,  5,
     0,  0,  0, 20, 20,  0,  0,  0,
     5, -5,-10,  0,  0,-10, -5,  5,
     5, 10, 10,-20,-20, 10, 10,  5,
     0,  0,  0,  0,  0,  0,  0,  0 ],
  n: [
   -50,-40,-30,-30,-30,-30,-40,-50,
   -40,-20,  0,  0,  0,  0,-20,-40,
   -30,  0, 10, 15, 15, 10,  0,-30,
   -30,  5, 15, 20, 20, 15,  5,-30,
   -30,  0, 15, 20, 20, 15,  0,-30,
   -30,  5, 10, 15, 15, 10,  5,-30,
   -40,-20,  0,  5,  5,  0,-20,-40,
   -50,-40,-30,-30,-30,-30,-40,-50 ],
  b: [
   -20,-10,-10,-10,-10,-10,-10,-20,
   -10,  0,  0,  0,  0,  0,  0,-10,
   -10,  0,  5, 10, 10,  5,  0,-10,
   -10,  5,  5, 10, 10,  5,  5,-10,
   -10,  0, 10, 10, 10, 10,  0,-10,
   -10, 10, 10, 10, 10, 10, 10,-10,
   -10,  5,  0,  0,  0,  0,  5,-10,
   -20,-10,-10,-10,-10,-10,-10,-20 ],
  r: [
     0,  0,  0,  0,  0,  0,  0,  0,
     5, 10, 10, 10, 10, 10, 10,  5,
    -5,  0,  0,  0,  0,  0,  0, -5,
    -5,  0,  0,  0,  0,  0,  0, -5,
    -5,  0,  0,  0,  0,  0,  0, -5,
    -5,  0,  0,  0,  0,  0,  0, -5,
    -5,  0,  0,  0,  0,  0,  0, -5,
     0,  0,  0,  5,  5,  0,  0,  0 ],
  q: [
   -20,-10,-10, -5, -5,-10,-10,-20,
   -10,  0,  0,  0,  0,  0,  0,-10,
   -10,  0,  5,  5,  5,  5,  0,-10,
    -5,  0,  5,  5,  5,  5,  0, -5,
     0,  0,  5,  5,  5,  5,  0, -5,
   -10,  5,  5,  5,  5,  5,  0,-10,
   -10,  0,  5,  0,  0,  0,  0,-10,
   -20,-10,-10, -5, -5,-10,-10,-20 ],
  kMid: [
   -30,-40,-40,-50,-50,-40,-40,-30,
   -30,-40,-40,-50,-50,-40,-40,-30,
   -30,-40,-40,-50,-50,-40,-40,-30,
   -30,-40,-40,-50,-50,-40,-40,-30,
   -20,-30,-30,-40,-40,-30,-30,-20,
   -10,-20,-20,-20,-20,-20,-20,-10,
    20, 20,  0,  0,  0,  0, 20, 20,
    20, 30, 10,  0,  0, 10, 30, 20 ],
  kEnd: [
   -50,-40,-30,-20,-20,-30,-40,-50,
   -30,-20,-10,  0,  0,-10,-20,-30,
   -30,-10, 20, 30, 30, 20,-10,-30,
   -30,-10, 30, 40, 40, 30,-10,-30,
   -30,-10, 30, 40, 40, 30,-10,-30,
   -30,-10, 20, 30, 30, 20,-10,-30,
   -30,-30,  0,  0,  0,  0,-30,-30,
   -50,-30,-30,-30,-30,-30,-30,-50 ]
};
/* table index for a 0x88 square, from white's / black's point of view */
const pstIndex = (sq, white) => white ? ((7 - rankOf(sq)) * 8 + fileOf(sq))
                                      : (rankOf(sq) * 8 + fileOf(sq));

/**
 * Static evaluation, from the point of view of the side to move.
 * Positive = good for the side to move.
 */
function evaluate(st) {
  const board = st.board;
  const M = CONFIG.MATERIAL;
  let score = 0, nonPawn = 0, bishops = { w:0, b:0 };

  for (let r = 0; r < 8; r++) {
    for (let f = 0; f < 8; f++) {
      const sq = r * 16 + f;
      const p = board[sq];
      if (!p) continue;
      const white = (p === p.toUpperCase());
      const t = p.toLowerCase();
      const sign = white ? 1 : -1;
      const idx = pstIndex(sq, white);
      let v = M[t];
      if (t === KING) {
        /* king table chosen lazily below — use mid for now, patched after phase */
        v += PST.kMid[idx];
      } else {
        v += PST[t][idx] * CONFIG.PST_WEIGHT;
        if (t !== PAWN) nonPawn += M[t];
        if (t === BISHOP) bishops[white ? 'w' : 'b']++;
      }
      score += sign * v;
    }
  }
  /* endgame king table swap */
  const endgame = nonPawn < 1300;
  if (endgame) {
    for (const c of ['w','b']) {
      const ksq = st.kings[c];
      if (ksq < 0) continue;
      const white = (c === 'w');
      score += (white ? 1 : -1) * (PST.kEnd[pstIndex(ksq, white)] - PST.kMid[pstIndex(ksq, white)]);
    }
  }
  if (bishops.w >= 2) score += CONFIG.BISHOP_PAIR;
  if (bishops.b >= 2) score -= CONFIG.BISHOP_PAIR;

  /* tiny nudge so the engine prefers something over shuffling when equal */
  score += (st.turn === 'w' ? 6 : -6);
  return st.turn === 'w' ? score : -score;
}

/* ==========================================================================
   8. SEARCH — negamax + alpha-beta + quiescence + iterative deepening
   ========================================================================== */
const INF = 1e9;
const searchCtx = { deadline: 0, aborted: false, nodes: 0 };

/** Most-valuable-victim / least-valuable-attacker ordering, promotions next. */
function orderMoves(moves) {
  const M = CONFIG.MATERIAL;
  for (const m of moves) {
    let s = 0;
    if (m.capture) s += 1000000 + 10 * M[typeOf(m.capture)] - M[typeOf(m.piece)];
    if (m.promotion) s += 900000 + M[m.promotion];
    if (m.flags & FLAGS.KSIDE_CASTLE || m.flags & FLAGS.QSIDE_CASTLE) s += 500;
    m._o = s;
  }
  moves.sort((a, b) => b._o - a._o);
  return moves;
}

const timeUp = () => {
  if ((searchCtx.nodes & 1023) === 0 && performance.now() > searchCtx.deadline) {
    searchCtx.aborted = true;
  }
  return searchCtx.aborted;
};

/** Quiescence search — captures only (plus check evasions), kills the horizon effect. */
function quiesce(st, alpha, beta, qd) {
  searchCtx.nodes++;
  if (timeUp()) return evaluate(st);

  if (inCheck(st)) {
    if (qd >= CONFIG.QUIESCE_MAX) return evaluate(st);
    const moves = orderMoves(legalMoves(st));
    if (!moves.length) return -CONFIG.MATE + 100;      // mate on the board
    let best = -INF;
    for (const m of moves) {
      const u = makeMove(st, m);
      const s = -quiesce(st, -beta, -alpha, qd + 1);
      unmakeMove(st, u);
      if (s > best) best = s;
      if (best > alpha) alpha = best;
      if (alpha >= beta) break;
      if (searchCtx.aborted) break;
    }
    return best;
  }

  let stand = evaluate(st);
  if (stand >= beta) return stand;
  if (stand > alpha) alpha = stand;
  if (qd >= CONFIG.QUIESCE_MAX) return stand;

  const caps = orderMoves(legalMoves(st, { capturesOnly: true }));
  let best = stand;
  for (const m of caps) {
    const u = makeMove(st, m);
    const s = -quiesce(st, -beta, -alpha, qd + 1);
    unmakeMove(st, u);
    if (s > best) best = s;
    if (best > alpha) alpha = best;
    if (alpha >= beta) break;
    if (searchCtx.aborted) break;
  }
  return best;
}

/** Plain negamax with alpha-beta. Returns score from the side-to-move's view. */
function negamax(st, depth, alpha, beta, ply) {
  searchCtx.nodes++;
  if (timeUp()) return evaluate(st);

  if (st.halfmoves >= CONFIG.FIFTY_MOVE_DRAW || insufficientMaterial(st)) return 0;
  if (depth <= 0) return quiesce(st, alpha, beta, 0);

  const moves = orderMoves(legalMoves(st));
  if (!moves.length) {
    return inCheck(st) ? (-CONFIG.MATE + ply) : 0;      // mate / stalemate
  }

  let best = -INF;
  for (const m of moves) {
    const u = makeMove(st, m);
    const s = -negamax(st, depth - 1, -beta, -alpha, ply + 1);
    unmakeMove(st, u);
    if (s > best) best = s;
    if (best > alpha) alpha = best;
    if (alpha >= beta) break;                            // beta cut-off
    if (searchCtx.aborted) break;
  }
  return best;
}

const yieldToUI = () => new Promise(res => setTimeout(res, 0));

/**
 * Iterative-deepening root search. Yields to the event loop so the UI never
 * freezes, and honours a wall-clock budget.
 * @returns {Promise<{move:object, score:number, depth:number, nodes:number}>}
 */
async function searchBestMove(st, maxDepth, budgetMs) {
  const root = legalMoves(st);
  if (!root.length) return null;

  searchCtx.nodes = 0;
  searchCtx.aborted = false;
  searchCtx.deadline = performance.now() + (budgetMs || CONFIG.AI_TIME_BUDGET);

  let best = orderMoves(root.slice())[0];
  let bestScore = 0, reachedDepth = 0;
  let lastYield = performance.now();

  for (let depth = 1; depth <= maxDepth; depth++) {
    let alpha = -INF, localBest = null, localScore = -INF, completed = 0;
    /* search the previous best move first — better pruning */
    const ordered = orderMoves(root.slice()).sort((a, b) => (a === best ? -1 : b === best ? 1 : 0));

    for (const m of ordered) {
      const u = makeMove(st, m);
      const s = -negamax(st, depth - 1, -INF, -alpha, 1);
      unmakeMove(st, u);
      if (searchCtx.aborted) break;
      completed++;
      if (s > localScore) { localScore = s; localBest = m; }
      if (s > alpha) alpha = s;
      if (performance.now() - lastYield > CONFIG.AI_YIELD_MS) {
        lastYield = performance.now();
        await yieldToUI();
      }
    }
    /* keep partial results: a fully searched root move is better than nothing */
    if (localBest && completed > 0) {
      best = localBest; bestScore = localScore; reachedDepth = depth;
    }
    if (searchCtx.aborted) break;
    if (bestScore > CONFIG.MATE - 200) break;             // mate found, stop early
    if (performance.now() > searchCtx.deadline) break;
  }
  return { move: best, score: bestScore, depth: reachedDepth, nodes: searchCtx.nodes };
}

/* ==========================================================================
   9. AUDIO — tiny Web Audio synth (no assets)
   ========================================================================== */
const Sfx = {
  ctx: null,
  muted: false,
  ensure() {
    if (!this.ctx) {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return null;
      try { this.ctx = new AC(); } catch (e) { return null; }
    }
    if (this.ctx.state === 'suspended') this.ctx.resume();
    return this.ctx;
  },
  /** simple enveloped oscillator */
  tone(freq, dur, type, vol, slideTo) {
    if (this.muted) return;
    const ctx = this.ensure(); if (!ctx) return;
    const o = ctx.createOscillator(), g = ctx.createGain();
    o.type = type || 'sine';
    o.frequency.setValueAtTime(freq, ctx.currentTime);
    if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), ctx.currentTime + dur);
    g.gain.setValueAtTime(0.0001, ctx.currentTime);
    g.gain.exponentialRampToValueAtTime(vol || 0.14, ctx.currentTime + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
    o.connect(g); g.connect(ctx.destination);
    o.start(); o.stop(ctx.currentTime + dur + 0.02);
  },
  /** filtered noise burst — the "clack" of a piece hitting wood */
  noise(dur, vol, freq) {
    if (this.muted) return;
    const ctx = this.ensure(); if (!ctx) return;
    const len = Math.floor(ctx.sampleRate * dur);
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
    const src = ctx.createBufferSource(); src.buffer = buf;
    const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq || 1400; bp.Q.value = 1.1;
    const g = ctx.createGain(); g.gain.value = vol || 0.16;
    src.connect(bp); bp.connect(g); g.connect(ctx.destination);
    src.start();
  },
  move()    { this.noise(0.05, 0.13, 1100); this.tone(196, 0.07, 'triangle', 0.05, 150); },
  capture() { this.noise(0.11, 0.22, 700);  this.tone(120, 0.16, 'square',   0.09,  70); },
  check()   { this.tone(880, 0.10, 'square', 0.08); setTimeout(() => this.tone(1320, 0.16, 'square', 0.08), 95); },
  castle()  { this.move(); setTimeout(() => this.move(), 90); },
  promote() { this.tone(523, 0.09, 'triangle', 0.08); setTimeout(() => this.tone(784, 0.12, 'triangle', 0.08), 80); },
  end()     { this.tone(392, 0.16, 'sine', 0.10); setTimeout(() => this.tone(294, 0.30, 'sine', 0.10), 150); }
};

/* ==========================================================================
   10. PERSISTENCE
   ========================================================================== */
const Store = {
  save(g) {
    try {
      localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
        v: 1,
        startFen: g.startFen,
        moves: g.history.map(h => moveToUci(h.move)),
        playerColor: g.playerColor,
        depth: g.depth,
        flipped: g.flipped,
        muted: Sfx.muted
      }));
    } catch (e) { /* private mode / quota — ignore */ }
  },
  load() {
    try {
      const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
      if (!raw) return null;
      const d = JSON.parse(raw);
      if (!d || d.v !== 1 || typeof d.startFen !== 'string') return null;
      return d;
    } catch (e) { return null; }
  },
  clear() { try { localStorage.removeItem(CONFIG.STORAGE_KEY); } catch (e) {} }
};

/* ==========================================================================
   11. GAME CONTROLLER
   ========================================================================== */
const G = {
  st: null,
  history: [],        // [{ move, san, undo, captured }]
  startFen: START_FEN,
  playerColor: 'w',   // 'w' | 'b' | 'both'
  depth: CONFIG.AI_DEFAULT,
  flipped: false,
  thinking: false,
  epoch: 0,           // bumped on new game / undo — stale AI results are dropped
  selected: -1,
  targets: new Map(), // to-square -> [legal moves]
  status: null,
  bannerDismissed: false
};

const GLYPH = { k:'\u265A', q:'\u265B', r:'\u265C', b:'\u265D', n:'\u265E', p:'\u265F' };
const PROMO_ORDER = [QUEEN, ROOK, BISHOP, KNIGHT];

/* ---------- DOM refs ---------- */
const el = {
  board: document.getElementById('board'),
  ranks: document.getElementById('ranks'),
  files: document.getElementById('files'),
  moves: document.getElementById('moves'),
  trayW: document.getElementById('trayW'),
  trayB: document.getElementById('trayB'),
  scoreW: document.getElementById('scoreW'),
  scoreB: document.getElementById('scoreB'),
  turnDisc: document.getElementById('turnDisc'),
  turnTxt: document.getElementById('turnTxt'),
  promo: document.getElementById('promo'),
  banner: document.getElementById('banner'),
  bannerRes: document.getElementById('bannerRes'),
  bannerWhy: document.getElementById('bannerWhy'),
  plyCount: document.getElementById('plyCount'),
  btnNew: document.getElementById('btnNew'),
  btnUndo: document.getElementById('btnUndo'),
  btnFlip: document.getElementById('btnFlip'),
  btnMute: document.getElementById('btnMute'),
  segColor: document.getElementById('segColor'),
  segDepth: document.getElementById('segDepth')
};

const sqEls = new Map();          // 0x88 index -> element
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const isLight = (sq) => ((fileOf(sq) + rankOf(sq)) & 1) === 1;
/** display order 0..63 for a square (0 = top-left when not flipped) */
const displayOrder = (sq) => (7 - rankOf(sq)) * 8 + fileOf(sq);

/* ---------------- new game / setup ---------------- */
function newGame(opts) {
  opts = opts || {};
  G.epoch++;
  G.thinking = false;
  G.history = [];
  G.startFen = opts.fen || START_FEN;
  G.st = loadFen(G.startFen);
  G.selected = -1;
  G.targets = new Map();
  G.bannerDismissed = false;
  if (opts.playerColor) G.playerColor = opts.playerColor;
  G.status = gameStatus(G.st);
  hidePromo(); hideBanner();
  renderAll();
  Store.save(G);
  maybeAI();
}

/** Rebuilds a game from a saved record (start FEN + UCI move list). */
function restoreGame(data) {
  G.epoch++;
  G.thinking = false;
  G.startFen = data.startFen;
  G.st = loadFen(data.startFen);
  G.history = [];
  G.selected = -1;
  G.targets = new Map();
  G.bannerDismissed = false;
  G.playerColor = (data.playerColor === 'b' || data.playerColor === 'both') ? data.playerColor : 'w';
  G.depth = CONFIG.AI_DEPTHS.includes(+data.depth) ? +data.depth : CONFIG.AI_DEFAULT;
  G.flipped = !!data.flipped;
  Sfx.muted = !!data.muted;
  for (const uci of (data.moves || [])) {
    const m = findMoveByUci(G.st, uci);
    if (!m) break;                       // corrupt save — keep what replays cleanly
    const legal = legalMoves(G.st);
    const san = moveToSan(G.st, m, legal);
    const undo = makeMove(G.st, m);
    G.history.push({ move: m, san, undo, captured: m.capture });
  }
  G.status = gameStatus(G.st);
  hidePromo(); hideBanner();
  renderAll();
  if (G.status.over) showBanner();
  else maybeAI();
}

/* ---------------- making moves ---------------- */
/** Applies a legal move, updates history/UI/sound/persistence. */
function doMove(m) {
  const legal = legalMoves(G.st);
  const san = moveToSan(G.st, m, legal);
  const undo = makeMove(G.st, m);

  G.history.push({ move: m, san, undo, captured: m.capture });
  G.selected = -1;
  G.targets = new Map();
  hidePromo();

  /* sound */
  if (m.flags & FLAGS.EP_CAPTURE) Sfx.capture();
  else if (m.flags & FLAGS.CAPTURE) Sfx.capture();
  else if (m.flags & (FLAGS.KSIDE_CASTLE | FLAGS.QSIDE_CASTLE)) Sfx.castle();
  else if (m.flags & FLAGS.PROMOTION) Sfx.promote();
  else Sfx.move();

  G.status = gameStatus(G.st);
  renderAll();
  Store.save(G);

  if (G.status.over) {
    Sfx.end();
    showBanner();
    return;
  }
  if (G.status.check) Sfx.check();
  maybeAI();
}

/** Is it the human's turn right now? */
function humanTurn() {
  return !G.thinking && !G.status.over &&
         (G.playerColor === 'both' || G.st.turn === G.playerColor);
}

/** Starts the AI if it is the engine's move. */
function maybeAI() {
  if (G.status.over) return;
  if (G.playerColor === 'both') return;
  if (G.st.turn === G.playerColor) return;
  think();
}

async function think() {
  if (G.thinking) return;
  G.thinking = true;
  const myEpoch = G.epoch;
  renderTurn();
  await sleep(CONFIG.AI_DELAY);
  if (myEpoch !== G.epoch || G.status.over) { G.thinking = false; renderTurn(); return; }

  const t0 = performance.now();
  const res = await searchBestMove(G.st, G.depth, CONFIG.AI_TIME_BUDGET);
  const elapsed = Math.round(performance.now() - t0);
  G.thinking = false;
  if (myEpoch !== G.epoch || !res) { renderTurn(); return; }
  if (G.status.over) { renderTurn(); return; }

  /* paranoia: never play an illegal move */
  const legalNow = legalMoves(G.st);
  if (!legalNow.some(m => m.from === res.move.from && m.to === res.move.to &&
                          m.promotion === res.move.promotion)) {
    doMove(legalNow[0]);
    return;
  }
  G.lastThink = { depth: res.depth, score: res.score, nodes: res.nodes, ms: elapsed };
  doMove(res.move);
}

/* ---------------- undo ---------------- */
function undoMove() {
  if (G.thinking || !G.history.length) return;
  G.epoch++;
  const n = (G.playerColor === 'both' || G.history.length < 2) ? 1 : 2;
  for (let i = 0; i < n && G.history.length; i++) {
    const h = G.history.pop();
    unmakeMove(G.st, h.undo);
  }
  G.selected = -1;
  G.targets = new Map();
  G.bannerDismissed = false;
  hidePromo(); hideBanner();
  G.status = gameStatus(G.st);
  renderAll();
  Store.save(G);
}

/* ---------------- selection / move attempt ---------------- */
function selectSquare(sq) {
  const piece = G.st.board[sq];
  if (!piece || colorOf(piece) !== G.st.turn) { clearSelection(); return; }
  const moves = legalMoves(G.st, { square: sq });
  if (!moves.length) { clearSelection(); return; }
  G.selected = sq;
  G.targets = new Map();
  for (const m of moves) {
    if (!G.targets.has(m.to)) G.targets.set(m.to, []);
    G.targets.get(m.to).push(m);
  }
  renderBoard();
}

function clearSelection() {
  if (G.selected === -1 && G.targets.size === 0) return;
  G.selected = -1; G.targets = new Map(); renderBoard();
}

/** Human tried to go from → to. Returns true if something happened. */
function attemptMove(from, to) {
  const list = G.targets.get(to);
  if (!list) return false;
  if (list.length > 1) { showPromo(from, to, list); return true; }
  doMove(list[0]);
  return true;
}

function onSquareClick(sq) {
  if (!humanTurn()) return;
  if (G.selected !== -1 && G.targets.has(sq)) { attemptMove(G.selected, sq); return; }
  if (G.selected === sq) { clearSelection(); return; }
  selectSquare(sq);
}

/* ==========================================================================
   12. RENDERING
   ========================================================================== */
function buildBoard() {
  el.board.innerHTML = '';
  sqEls.clear();
  for (let r = 0; r < 8; r++) {
    for (let f = 0; f < 8; f++) {
      const sq = r * 16 + f;
      const d = document.createElement('div');
      d.className = 'sq ' + (isLight(sq) ? 'light' : 'dark');
      d.dataset.sq = String(sq);
      sqEls.set(sq, d);
      el.board.appendChild(d);
    }
  }
  applyFlip();
}

function applyFlip() {
  for (const [sq, d] of sqEls) {
    const o = displayOrder(sq);
    d.style.order = String(G.flipped ? (63 - o) : o);
  }
  el.ranks.innerHTML = ''; el.files.innerHTML = '';
  const ranks = G.flipped ? RANKS.slice() : RANKS.slice().reverse();
  const files = G.flipped ? FILES.slice().reverse() : FILES.slice();
  for (const x of ranks) { const d = document.createElement('div'); d.textContent = x; el.ranks.appendChild(d); }
  for (const x of files) { const d = document.createElement('div'); d.textContent = x; el.files.appendChild(d); }
}

function renderBoard() {
  const st = G.st, stt = G.status || gameStatus(st);
  const last = G.history.length ? G.history[G.history.length - 1].move : null;
  const checkSq = stt.check ? st.kings[st.turn] : -1;
  const canPick = humanTurn();
  for (const [sq, d] of sqEls) {
    let cls = 'sq ' + (isLight(sq) ? 'light' : 'dark');
    if (last && (sq === last.from || sq === last.to)) cls += ' last';
    if (sq === G.selected) cls += ' sel';
    const tgt = G.targets.get(sq);
    if (tgt) {
      cls += ' hint';
      if (tgt[0].flags & FLAGS.CAPTURE) cls += ' cap';
    } else if (canPick) {
      const p = st.board[sq];
      if (p && colorOf(p) === st.turn) cls += ' movable';
    }
    if (sq === checkSq) cls += ' check';
    d.className = cls;
    const p = st.board[sq];
    d.innerHTML = p ? '<span class="piece ' + colorOf(p) + '">' + GLYPH[typeOf(p)] + '</span>' : '';
  }
}

function renderTurn() {
  const t = G.st.turn;
  const stt = G.status || gameStatus(G.st);
  el.turnDisc.className = 'disc ' + (t === 'w' ? '' : 'b');
  let sub;
  if (stt.over) sub = stt.reason;
  else if (G.thinking) sub = 'engine thinking…';
  else if (G.playerColor === 'both') sub = 'analysis mode — you play both sides';
  else sub = (t === G.playerColor) ? 'your move' : 'engine to move';
  const name = t === 'w' ? 'White' : 'Black';
  el.turnTxt.innerHTML = name + ' to move<small' +
    (G.thinking ? ' class="think"' : '') + '>' + sub + '</small>';
  el.btnUndo.disabled = G.thinking || G.history.length === 0;
}

function renderMoves() {
  const h = G.history;
  if (!h.length) {
    el.moves.innerHTML = '<div class="empty">No moves yet.</div>';
    el.plyCount.textContent = '';
    return;
  }
  let html = '<table>';
  for (let i = 0; i < h.length; i += 2) {
    const curW = (i === h.length - 1) ? ' class="cur"' : '';
    const curB = (i + 1 === h.length - 1) ? ' class="cur"' : '';
    html += '<tr><td class="n">' + (i / 2 + 1) + '.</td>' +
            '<td class="m"><span' + curW + '>' + h[i].san + '</span></td>' +
            '<td class="m">' + (h[i + 1] ? '<span' + curB + '>' + h[i + 1].san + '</span>' : '') + '</td></tr>';
  }
  el.moves.innerHTML = html + '</table>';
  el.moves.scrollTop = el.moves.scrollHeight;
  el.plyCount.textContent = h.length + (h.length === 1 ? ' ply' : ' plies');
}

function renderTrays() {
  const lost = { w: [], b: [] };
  for (const h of G.history) if (h.captured) lost[colorOf(h.captured)].push(h.captured);
  const val = (a) => a.reduce((s, p) => s + CONFIG.MATERIAL[typeOf(p)], 0);
  el.trayW.innerHTML = lost.w.map(p => '<span class="p w">' + GLYPH[typeOf(p)] + '</span>').join('');
  el.trayB.innerHTML = lost.b.map(p => '<span class="p b">' + GLYPH[typeOf(p)] + '</span>').join('');
  const diff = val(lost.b) - val(lost.w);
  el.scoreW.textContent = diff > 0 ? '+' + diff : '';
  el.scoreB.textContent = diff < 0 ? '+' + (-diff) : '';
}

function renderAll() {
  renderBoard(); renderTurn(); renderMoves(); renderTrays();
}

/* ==========================================================================
   13. OVERLAYS — promotion picker & game-over banner
   ========================================================================== */
function hidePromo() { el.promo.classList.add('hidden'); }

function showPromo(from, to, list) {
  const white = colorOf(G.st.board[from]) === 'w';
  el.promo.innerHTML = '';
  el.promo.className = 'vert';
  const names = { q:'Queen', r:'Rook', b:'Bishop', n:'Knight' };
  for (const p of PROMO_ORDER) {
    const m = list.find(mm => mm.promotion === p);
    if (!m) continue;
    const b = document.createElement('button');
    b.innerHTML = '<span class="' + (white ? '' : 'pb') + '">' + GLYPH[p] + '</span>';
    b.title = names[p];
    b.addEventListener('click', (ev) => {
      ev.stopPropagation();
      hidePromo();
      doMove(m);
    });
    el.promo.appendChild(b);
  }
  el.promo.classList.remove('hidden');
  const r = sqEls.get(to).getBoundingClientRect();
  const pw = el.promo.offsetWidth, ph = el.promo.offsetHeight;
  let left = r.left + r.width / 2 - pw / 2;
  let top = white ? (r.top - ph - 4) : (r.bottom + 4);
  left = Math.max(8, Math.min(window.innerWidth - pw - 8, left));
  top  = Math.max(8, Math.min(window.innerHeight - ph - 8, top));
  el.promo.style.left = left + 'px';
  el.promo.style.top = top + 'px';
}

function hideBanner() { el.banner.classList.add('hidden'); }

function showBanner() {
  const s = G.status;
  let res, why;
  if (s.result === 'checkmate') {
    const w = s.winner === 'w' ? 'White' : 'Black';
    res = w + ' wins';
    why = 'Checkmate';
    if (G.playerColor !== 'both') {
      why += (s.winner === G.playerColor) ? ' — you beat the engine.' : ' — the engine beat you.';
    }
  } else if (s.result === 'stalemate') {
    res = 'Draw'; why = 'Stalemate — ' + (G.st.turn === 'w' ? 'White' : 'Black') +
      ' has no legal move but is not in check.';
  } else {
    res = 'Draw'; why = s.reason || 'Draw';
  }
  el.bannerRes.textContent = res;
  el.bannerWhy.textContent = why;
  el.banner.classList.remove('hidden');
}

/* ==========================================================================
   14. INTERACTION — click-to-move + drag & drop
   ========================================================================== */
let drag = null;   // { sq, x0, y0, active, ghost }

function squareFromPoint(x, y) {
  const node = document.elementFromPoint(x, y);
  const d = node && node.closest ? node.closest('.sq') : null;
  return d ? +d.dataset.sq : -1;
}

el.board.addEventListener('pointerdown', (e) => {
  if (!humanTurn()) return;
  const d = e.target.closest ? e.target.closest('.sq') : null;
  if (!d) return;
  const sq = +d.dataset.sq;
  /* a click on an already-offered target square is handled on pointerup */
  if (G.selected !== -1 && G.targets.has(sq)) { drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:true }; return; }
  const p = G.st.board[sq];
  if (!p || colorOf(p) !== G.st.turn) { drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:true }; return; }
  drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:false };
});

window.addEventListener('pointermove', (e) => {
  if (!drag || drag.active || drag.noDrag) return;
  if (Math.abs(e.clientX - drag.x0) + Math.abs(e.clientY - drag.y0) < 6) return;
  if (!humanTurn()) { drag = null; return; }
  drag.active = true;
  selectSquare(drag.sq);                       // shows the legal-destination dots
  const src = sqEls.get(drag.sq);
  if (src) src.classList.add('drag-src');
  const p = G.st.board[drag.sq];
  const g = document.createElement('span');
  g.className = 'piece ghost ' + colorOf(p);
  g.textContent = GLYPH[typeOf(p)];
  g.style.left = e.clientX + 'px';
  g.style.top = e.clientY + 'px';
  document.body.appendChild(g);
  drag.ghost = g;
});

window.addEventListener('pointermove', (e) => {
  if (drag && drag.ghost) { drag.ghost.style.left = e.clientX + 'px'; drag.ghost.style.top = e.clientY + 'px'; }
});

window.addEventListener('pointerup', (e) => {
  if (!drag) return;
  const d = drag; drag = null;
  if (d.ghost) d.ghost.remove();
  const src = sqEls.get(d.sq);
  if (src) src.classList.remove('drag-src');

  if (!d.active) { onSquareClick(d.sq); return; }

  const to = squareFromPoint(e.clientX, e.clientY);
  if (to === d.sq) { onSquareClick(d.sq); return; }          // tap = select
  if (to >= 0 && G.targets.has(to)) { attemptMove(d.sq, to); return; }
  clearSelection();
});

window.addEventListener('pointercancel', () => {
  if (!drag) return;
  if (drag.ghost) drag.ghost.remove();
  const src = sqEls.get(drag.sq);
  if (src) src.classList.remove('drag-src');
  drag = null;
});

/* ==========================================================================
   15. CONTROLS
   ========================================================================== */
function syncControls() {
  for (const b of el.segColor.querySelectorAll('button'))
    b.classList.toggle('on', b.dataset.v === G.playerColor);
  for (const b of el.segDepth.querySelectorAll('button'))
    b.classList.toggle('on', +b.dataset.v === G.depth);
  el.btnMute.textContent = 'Sound: ' + (Sfx.muted ? 'off' : 'on');
}

el.segColor.addEventListener('click', (e) => {
  const b = e.target.closest('button'); if (!b) return;
  G.playerColor = b.dataset.v;
  G.epoch++;                       // drop any in-flight engine result
  syncControls(); Store.save(G);
  clearSelection(); renderAll();
  maybeAI();
});

el.segDepth.addEventListener('click', (e) => {
  const b = e.target.closest('button'); if (!b) return;
  G.depth = +b.dataset.v;
  syncControls(); Store.save(G);
});

el.btnNew.addEventListener('click', () => newGame());
el.btnUndo.addEventListener('click', () => undoMove());
el.btnFlip.addEventListener('click', () => { G.flipped = !G.flipped; applyFlip(); Store.save(G); });
el.btnMute.addEventListener('click', () => { Sfx.muted = !Sfx.muted; syncControls(); Store.save(G); });
document.getElementById('bannerNew').addEventListener('click', () => newGame());
document.getElementById('bannerClose').addEventListener('click', () => {
  G.bannerDismissed = true; hideBanner();
});

window.addEventListener('keydown', (e) => {
  if (e.target && /input|textarea/i.test(e.target.tagName)) return;
  const k = e.key.toLowerCase();
  if (k === 'u' || k === 'arrowleft') { undoMove(); }
  else if (k === 'f') { G.flipped = !G.flipped; applyFlip(); Store.save(G); }
  else if (k === 'n') { newGame(); }
  else if (k === 'm') { Sfx.muted = !Sfx.muted; syncControls(); Store.save(G); }
  else if (k === 'escape') { clearSelection(); hidePromo(); }
});

/* ==========================================================================
   16. PERFT (debug helper — counts leaf nodes of the legal-move tree)
   ========================================================================== */
function perft(st, depth) {
  if (depth <= 0) return 1;
  let nodes = 0;
  const moves = legalMoves(st);
  if (depth === 1) return moves.length;
  for (const m of moves) {
    const u = makeMove(st, m);
    nodes += perft(st, depth - 1);
    unmakeMove(st, u);
  }
  return nodes;
}

/* ==========================================================================
   17. DEBUG INTERFACE (window.game) — probe the engine headlessly
   ========================================================================== */
window.game = {
  /* --- position --- */
  fen: () => toFen(G.st),
  setFen: (fen, color) => { newGame({ fen, playerColor: color || 'both' }); return toFen(G.st); },
  ascii: () => {
    let out = '';
    for (let r = 7; r >= 0; r--) {
      out += (r + 1) + ' ';
      for (let f = 0; f < 8; f++) { const p = G.st.board[r * 16 + f]; out += (p ? p : '.') + ' '; }
      out += '\n';
    }
    return out + '  a b c d e f g h';
  },
  /* --- moves --- */
  legalMoves: (square) => legalMoves(G.st, {
    square: square === undefined ? null : (typeof square === 'string' ? SQUARES[square] : square)
  }).map(moveToUci),
  legalMoveObjects: (square) => legalMoves(G.st, {
    square: square === undefined ? null : (typeof square === 'string' ? SQUARES[square] : square)
  }),
  /** Engine-level move: applies it, does NOT trigger the AI. Returns SAN or null. */
  move: (uci) => {
    const m = findMoveByUci(G.st, uci);
    if (!m) return null;
    const san = moveToSan(G.st, m, legalMoves(G.st));
    const undo = makeMove(G.st, m);
    G.history.push({ move: m, san, undo, captured: m.capture });
    G.selected = -1; G.targets = new Map();
    G.status = gameStatus(G.st);
    hidePromo();
    renderAll(); Store.save(G);
    if (G.status.over) showBanner();
    return san;
  },
  /** UI-level move: applies it and lets the AI reply. */
  play: (uci) => {
    const m = findMoveByUci(G.st, uci);
    if (!m) return false;
    doMove(m); return true;
  },
  undo: () => { undoMove(); return G.history.length; },
  newGame: (color) => { newGame({ playerColor: color || G.playerColor }); return toFen(G.st); },
  history: () => G.history.map(h => h.san),
  status: () => ({
    turn: G.st.turn, check: !!G.status.check, over: !!G.status.over,
    result: G.status.result, reason: G.status.reason,
    ep: G.st.ep >= 0 ? ALGEBRAIC[G.st.ep] : '-',
    castling: toFen(G.st).split(' ')[2],
    thinking: G.thinking
  }),
  evalScore: () => evaluate(G.st),
  perft: (d) => perft(G.st, d || 1),
  setDepth: (d) => { G.depth = d; syncControls(); Store.save(G); },
  setColor: (c) => { G.playerColor = c; syncControls(); Store.save(G); },
  G,
  /* raw engine, usable without touching the UI state */
  engine: { loadFen, toFen, generateMoves, legalMoves, makeMove, unmakeMove,
            isAttacked, inCheck, gameStatus, evaluate, searchBestMove, perft,
            moveToUci, moveToSan, findMoveByUci, SQUARES, ALGEBRAIC, FLAGS }
};

/* ==========================================================================
   18. BOOT
   ========================================================================== */
buildBoard();
const saved = Store.load();
if (saved) restoreGame(saved);
else newGame();
syncControls();
</script>
</body>
</html>