← Chess results

Chess

Qwen 3.8 · 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 52,584 bytes · SHA-256 c35c380f6233
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess — vs Computer</title>
<style>
/* ================= THEME & LAYOUT ================= */
:root{
  --bs: min(600px, 92vw, calc(100vh - 250px));
  --sq-light:#efd9b4; --sq-dark:#a97a55;
  --gold:#c9a227; --gold-soft:#8a7433;
  --ink:#e9e2d2; --ink-dim:#98a1b0;
  --panel:#222834; --line:#333c4b;
  --bg:#191d24;
}
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{ min-height:100%; }
body{
  font-family:"Segoe UI", system-ui, -apple-system, sans-serif;
  background:radial-gradient(1100px 700px at 50% 10%, #27303e 0%, var(--bg) 55%, #12151b 100%);
  color:var(--ink);
}
#app{ max-width:1180px; margin:0 auto; padding:16px 20px 28px; }
header{ display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
h1{ font-family:Georgia,"Times New Roman",serif; font-size:26px; letter-spacing:.5px; font-weight:600; }
h1 .knight{ color:var(--gold); }
.tag{ color:var(--ink-dim); font-size:13px; }
main{ display:flex; gap:20px; align-items:flex-start; }
#board-col{ display:flex; flex-direction:column; align-items:center; gap:8px; }
#board-frame{
  padding:12px; border-radius:12px;
  background:linear-gradient(145deg,#4d3a2b,#33261c 60%,#2a1f15);
  box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08);
}
#board{
  position:relative; display:grid; grid-template-columns:repeat(8,1fr);
  width:var(--bs); height:var(--bs); border-radius:4px; overflow:hidden;
  touch-action:none; user-select:none; -webkit-user-select:none; cursor:default;
}
.sq{ position:relative; }
.sq.light{ background:var(--sq-light); }
.sq.dark{ background:var(--sq-dark); }
.wp{ color:#f8f3e4; -webkit-text-stroke:1px #5c4c38; text-shadow:0 2px 3px rgba(0,0,0,.35); }
.bp{ color:#241d15; -webkit-text-stroke:1px rgba(255,250,235,.16); text-shadow:0 2px 3px rgba(0,0,0,.45); }
.sq .piece{
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  font-size:calc(var(--bs)/8*.74); line-height:1;
  font-family:"Segoe UI Symbol","Noto Sans Symbols 2","Noto Sans Symbols","DejaVu Sans",sans-serif;
}
.piece.dragging{ opacity:.25; }
.sq.last::after{ content:''; position:absolute; inset:0; background:rgba(250,210,90,.42); pointer-events:none; }
.sq.incheck::after{
  content:''; position:absolute; inset:0; pointer-events:none;
  background:radial-gradient(circle at 50% 50%, rgba(230,60,45,.9) 0%, rgba(230,60,45,.4) 48%, transparent 72%);
}
.sq.selected{ box-shadow:inset 0 0 0 3px var(--gold); }
.sq.dragover{ box-shadow:inset 0 0 0 3px rgba(255,255,255,.8); }
.hint{
  position:absolute; left:50%; top:50%; width:30%; height:30%;
  transform:translate(-50%,-50%); border-radius:50%;
  background:rgba(35,25,8,.30); pointer-events:none;
}
.hint.cap{ width:92%; height:92%; background:transparent; border:4px solid rgba(35,25,8,.32); }
.coord{
  position:absolute; font-style:normal; font-weight:700; pointer-events:none;
  font-size:calc(var(--bs)/8*.2);
}
.coord.rank{ top:5%; left:7%; }
.coord.file{ bottom:4%; right:7%; }
.sq.light .coord{ color:var(--sq-dark); }
.sq.dark .coord{ color:var(--sq-light); }
.tray{
  display:flex; align-items:center; gap:1px; min-height:26px;
  width:calc(var(--bs) + 24px); padding:0 4px;
  font-size:19px; line-height:1;
  font-family:"Segoe UI Symbol","Noto Sans Symbols 2","Noto Sans Symbols","DejaVu Sans",sans-serif;
}
.tray .gs{ margin:0 1px; }
.tray .mat{ margin-left:6px; font-size:12px; color:var(--gold); font-family:"Segoe UI",sans-serif; font-weight:700; }
.hint-line{ font-size:12px; color:var(--ink-dim); }
/* overlays */
.overlay{
  position:absolute; inset:0; z-index:30;
  background:rgba(10,12,16,.62); backdrop-filter:blur(2px);
  display:flex; align-items:center; justify-content:center;
}
.overlay.hidden{ display:none; }
.over-card{
  background:linear-gradient(180deg,#2a313e,#20262f);
  border:1px solid var(--line); border-radius:14px;
  padding:26px 34px; text-align:center; box-shadow:0 20px 60px rgba(0,0,0,.6);
  max-width:80%;
}
.over-title{ font-family:Georgia,serif; font-size:28px; color:var(--gold); }
.over-sub{ margin:8px 0 18px; color:var(--ink-dim); font-size:14px; }
#promo{
  position:absolute; z-index:40;
  background:rgba(24,28,36,.97); border:1px solid var(--gold-soft); border-radius:10px;
  padding:6px; display:grid; grid-template-columns:1fr 1fr; gap:4px;
  box-shadow:0 10px 30px rgba(0,0,0,.5);
}
#promo.hidden{ display:none; }
#promo button{
  width:52px; height:52px; border-radius:8px; border:1px solid var(--line);
  background:#2b323e; cursor:pointer; font-size:32px; line-height:1;
  font-family:"Segoe UI Symbol","Noto Sans Symbols 2","Noto Sans Symbols","DejaVu Sans",sans-serif;
  display:flex; align-items:center; justify-content:center;
}
#promo button:hover{ border-color:var(--gold); background:#343c4a; }
#ghost{
  position:fixed; z-index:100; pointer-events:none;
  display:flex; align-items:center; justify-content:center;
  transform:translate(-50%,-55%);
  font-family:"Segoe UI Symbol","Noto Sans Symbols 2","Noto Sans Symbols","DejaVu Sans",sans-serif;
  filter:drop-shadow(0 6px 8px rgba(0,0,0,.45));
}
#ghost.hidden{ display:none; }
/* ================= SIDE PANEL ================= */
#panel{ width:320px; display:flex; flex-direction:column; gap:14px; }
.card{
  background:linear-gradient(180deg,#242b37,#1e242f);
  border:1px solid var(--line); border-radius:12px; padding:14px;
}
.ctl-label{
  font-size:11px; text-transform:uppercase; letter-spacing:1.2px;
  color:var(--ink-dim); margin:0 0 6px 2px;
}
.status-main{ font-size:17px; font-weight:600; display:flex; align-items:center; gap:9px; }
.dot{ width:12px; height:12px; border-radius:50%; display:inline-block; flex:none; }
.dot.w{ background:#f3ead6; box-shadow:0 0 0 1px rgba(0,0,0,.5); }
.dot.b{ background:#1d1710; box-shadow:0 0 0 1px rgba(255,255,255,.35); }
.thinking{ color:var(--gold); animation:pulse 1.1s ease-in-out infinite; font-weight:500; }
@keyframes pulse{ 0%,100%{ opacity:1; } 50%{ opacity:.35; } }
.status-sub{ margin-top:6px; font-size:13px; color:var(--ink-dim); min-height:16px; }
.status-sub .check{ color:#ff6b5e; font-weight:700; }
#eval-line{ font-size:12px; color:var(--ink-dim); margin-top:6px; font-family:ui-monospace,Menlo,Consolas,monospace; }
.seg{ display:flex; background:#171c24; border:1px solid var(--line); border-radius:8px; padding:2px; gap:2px; }
.seg button{
  flex:1; border:0; background:transparent; color:var(--ink-dim);
  padding:6px 4px; font-size:12.5px; border-radius:6px; cursor:pointer; font-family:inherit;
}
.seg button.on{
  background:linear-gradient(180deg,#3a4250,#2e3542); color:var(--ink);
  box-shadow:0 1px 3px rgba(0,0,0,.4); font-weight:600;
}
.btnrow{ display:flex; gap:8px; margin-top:14px; }
button.btn{
  flex:1; padding:9px 6px; border-radius:8px; border:1px solid var(--line);
  background:#232a35; color:var(--ink); font-size:13px; cursor:pointer; font-family:inherit;
}
button.btn:hover{ border-color:var(--gold-soft); color:#fff; }
button.btn:disabled{ opacity:.45; cursor:default; }
button.btn:disabled:hover{ border-color:var(--line); color:var(--ink); }
button.btn.primary{
  background:linear-gradient(180deg,#d4af37,#ad8a1d);
  color:#241c07; font-weight:700; border-color:#8a6d15;
}
button.btn.primary:hover{ color:#000; }
#moves-card{ display:flex; flex-direction:column; }
#moves{
  flex:1; overflow-y:auto; max-height:300px;
  font-family:ui-monospace,Menlo,Consolas,monospace; font-size:13px;
}
#moves::-webkit-scrollbar{ width:8px; }
#moves::-webkit-scrollbar-thumb{ background:#39424f; border-radius:4px; }
.mv-row{ display:grid; grid-template-columns:34px 1fr 1fr; padding:3px 6px; border-radius:5px; }
.mv-row:nth-child(odd){ background:rgba(255,255,255,.03); }
.mv-num{ color:var(--ink-dim); }
.mv.cur{ color:var(--gold); font-weight:700; }
.mv-empty{ color:var(--ink-dim); font-size:12.5px; padding:6px; }
@media (max-width:980px){
  main{ flex-direction:column; align-items:center; }
  #panel{ width:min(620px, 94vw); }
}
</style>
</head>
<body>
<div id="app">
  <header>
    <h1><span class="knight">&#9822;</span> Chess</h1>
    <span class="tag">Full rules &middot; Minimax AI &middot; Web Audio</span>
  </header>
  <main>
    <section id="board-col">
      <div class="tray" id="tray-top"></div>
      <div id="board-frame">
        <div id="board">
          <div id="promo" class="hidden"></div>
          <div id="over" class="overlay hidden">
            <div class="over-card">
              <div class="over-title" id="over-title"></div>
              <div class="over-sub" id="over-sub"></div>
              <button class="btn primary" id="over-new" style="flex:none;padding:10px 26px">New Game</button>
            </div>
          </div>
        </div>
      </div>
      <div class="tray" id="tray-bottom"></div>
      <div class="hint-line">Click or drag to move &middot; Esc cancels selection</div>
    </section>
    <aside id="panel">
      <div class="card" id="status-card">
        <div class="status-main" id="status-main"></div>
        <div class="status-sub" id="status-sub"></div>
        <div id="eval-line"></div>
      </div>
      <div class="card" id="controls-card">
        <div class="ctl-label">Difficulty</div>
        <div class="seg" id="seg-diff">
          <button data-v="novice">Novice</button><button data-v="club">Club</button><button data-v="expert">Expert</button>
        </div>
        <div class="ctl-label" style="margin-top:12px">Play as</div>
        <div class="seg" id="seg-color">
          <button data-v="white">White</button><button data-v="black">Black</button><button data-v="both">Both</button>
        </div>
        <div class="btnrow">
          <button class="btn" id="btn-undo">Undo</button>
          <button class="btn" id="btn-flip">Flip</button>
          <button class="btn" id="btn-sound">Sound: On</button>
          <button class="btn primary" id="btn-new">New Game</button>
        </div>
      </div>
      <div class="card" id="moves-card">
        <div class="ctl-label">Moves</div>
        <div id="moves"></div>
      </div>
    </aside>
  </main>
</div>
<div id="ghost" class="hidden"></div>

<script>
'use strict';
/* ================================================================
   1. TUNABLE CONSTANTS
   ================================================================ */
const CFG = {
  STORAGE_KEY: 'chess-ai-game-v1',
  START_FEN: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
  AI_DEPTHS: { novice: 1, club: 2, expert: 3 },   // difficulty -> search depth
  YIELD_EVERY_NODES: 1200,                          // search chunk size (keeps UI live)
  AI_CHUNK_MS: 4,                                   // delay between search chunks
  QUIESCENCE_DEPTH: 10,                             // capture-only horizon at leaf
  MATE_SCORE: 100000,
  FIFTY_MOVE_LIMIT: 100,                            // half-moves for the 50-move draw
  PIECE_VALUES: [0, 100, 320, 330, 500, 900, 20000],// P N B R Q K (centipawns)
  COLORS: { // mirrored into CSS vars at boot
    SQ_LIGHT: '#efd9b4', SQ_DARK: '#a97a55',
    GOLD: '#c9a227', INK: '#e9e2d2', BG: '#191d24'
  }
};

/* ================================================================
   2. BOARD REPRESENTATION  (64-square mailbox, index 0 = a8)
   piece codes: 0 empty, 1-6 white P N B R Q K, 7-12 black p n b r q k
   ================================================================ */
const EMPTY = 0, WP=1, WN=2, WB=3, WR=4, WQ=5, WK=6, BP=7, BN=8, BB=9, BR=10, BQ=11, BK=12;
const WHITE = 1, BLACK = -1;
const pieceColor = p => (p === 0 ? 0 : (p < 7 ? WHITE : BLACK));
const pieceType  = p => (p === 0 ? 0 : ((p + 5) % 6) + 1);   // 1..6
const fileOf = sq => sq & 7;
const rankOf = sq => sq >> 3;                                 // 0 = rank 8
const sqName = sq => 'abcdefgh'[sq & 7] + String(8 - (sq >> 3));
const nameToSq = n => (8 - +n[1]) * 8 + 'abcdefgh'.indexOf(n[0]);
const GLYPH = ['','\u265F','\u265E','\u265D','\u265C','\u265B','\u265A',
               '\u265F','\u265E','\u265D','\u265C','\u265B','\u265A']; // filled set, tinted via CSS

// precomputed neighbourhood tables
function buildNeigh(deltas){
  return Array.from({ length: 64 }, (_, sq) => {
    const f = sq & 7, r = sq >> 3, out = [];
    for (const [df, dr] of deltas){
      const nf = f + df, nr = r + dr;
      if (nf >= 0 && nf < 8 && nr >= 0 && nr < 8) out.push(nr * 8 + nf);
    }
    return out;
  });
}
const KN_N   = buildNeigh([[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]]);
const KING_N = buildNeigh([[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]]);
const ROOK_D = [[1,0],[-1,0],[0,1],[0,-1]];
const BISH_D = [[1,1],[1,-1],[-1,1],[-1,-1]];
const ALL_D  = ROOK_D.concat(BISH_D);

const mv = (from, to, promo = 0, flag = 0, cap = 0) => ({ from, to, promo, flag, cap });
// flags: 1 double push, 2 en passant, 4 castle kingside, 8 castle queenside

function parseFen(fen){
  const parts = fen.trim().split(/\s+/);
  const pos = parts[0], turn = parts[1] || 'w', castle = parts[2] || '-',
        ep = parts[3] || '-', half = parts[4] || '0', full = parts[5] || '1';
  const CODE = { P:WP, N:WN, B:WB, R:WR, Q:WQ, K:WK, p:BP, n:BN, b:BB, r:BR, q:BQ, k:BK };
  const board = new Int8Array(64);
  let sq = 0;
  for (const ch of pos){
    if (ch === '/') continue;
    if (ch >= '1' && ch <= '8') sq += +ch;
    else board[sq++] = CODE[ch];
  }
  return {
    board,
    turn: turn === 'b' ? BLACK : WHITE,
    castling: (castle.includes('K')?1:0)|(castle.includes('Q')?2:0)|(castle.includes('k')?4:0)|(castle.includes('q')?8:0),
    ep: ep === '-' ? -1 : nameToSq(ep),
    half: +half || 0,
    full: +full || 1,
    history: []
  };
}
function fenOf(s){
  const CH = ['','P','N','B','R','Q','K','p','n','b','r','q','k'];
  let out = '';
  for (let r = 0; r < 8; r++){
    let row = '', run = 0;
    for (let f = 0; f < 8; f++){
      const p = s.board[r*8+f];
      if (!p) run++;
      else { if (run){ row += run; run = 0; } row += CH[p]; }
    }
    if (run) row += run;
    out += row + (r < 7 ? '/' : '');
  }
  const c = ((s.castling&1)?'K':'') + ((s.castling&2)?'Q':'') + ((s.castling&4)?'k':'') + ((s.castling&8)?'q':'') || '-';
  return out + ' ' + (s.turn===WHITE?'w':'b') + ' ' + c + ' ' + (s.ep<0?'-':sqName(s.ep)) + ' ' + s.half + ' ' + s.full;
}
const posKey = s => fenOf(s).split(' ').slice(0, 4).join(' '); // (unused, kept for debugging)
function cloneState(s){
  return { board: new Int8Array(s.board), turn: s.turn, castling: s.castling,
           ep: s.ep, half: s.half, full: s.full, history: [] };
}

/* ================================================================
   3. ATTACK & CHECK
   ================================================================ */
function isSquareAttacked(b, sq, byColor){
  const f = sq & 7, r = sq >> 3;
  // pawns: a pawn of byColor attacks sq from one rank "behind" sq
  const pRow = byColor === WHITE ? r + 1 : r - 1;
  const pCode = byColor === WHITE ? WP : BP;
  if (pRow >= 0 && pRow < 8){
    if (f > 0 && b[pRow*8 + f - 1] === pCode) return true;
    if (f < 7 && b[pRow*8 + f + 1] === pCode) return true;
  }
  const nCode = byColor === WHITE ? WN : BN;
  for (const n of KN_N[sq]) if (b[n] === nCode) return true;
  const kCode = byColor === WHITE ? WK : BK;
  for (const n of KING_N[sq]) if (b[n] === kCode) return true;
  const rCode = byColor === WHITE ? WR : BR, qCode = byColor === WHITE ? WQ : BQ;
  for (const [df, dr] of ROOK_D){
    let nf = f + df, nr = r + dr;
    while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
      const p = b[nr*8+nf];
      if (p){ if (p === rCode || p === qCode) return true; break; }
      nf += df; nr += dr;
    }
  }
  for (const [df, dr] of BISH_D){
    let nf = f + df, nr = r + dr;
    while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
      const p = b[nr*8+nf];
      if (p){ if (p === qCode || (byColor===WHITE?WB:BB) === p) return true; break; }
      nf += df; nr += dr;
    }
  }
  return false;
}
function findKing(b, color){
  const k = color === WHITE ? WK : BK;
  for (let i = 0; i < 64; i++) if (b[i] === k) return i;
  return -1;
}
function inCheck(s, color){
  const k = findKing(s.board, color);
  return k >= 0 && isSquareAttacked(s.board, k, -color);
}

/* ================================================================
   4. MOVE GENERATION & MAKE / UNMAKE
   ================================================================ */
function genPseudo(s, out){
  const b = s.board, turn = s.turn, enemy = -turn;
  for (let sq = 0; sq < 64; sq++){
    const p = b[sq];
    if (!p || pieceColor(p) !== turn) continue;
    const t = pieceType(p), f = sq & 7, r = sq >> 3;
    if (t === 1){ // pawn
      const dir = turn === WHITE ? -8 : 8;
      const startR = turn === WHITE ? 6 : 1;
      const promoR = turn === WHITE ? 0 : 7;
      const fwd = sq + dir;
      if (fwd >= 0 && fwd < 64 && !b[fwd]){
        if ((fwd >> 3) === promoR)
          out.push(mv(sq,fwd,5), mv(sq,fwd,4), mv(sq,fwd,3), mv(sq,fwd,2));
        else out.push(mv(sq, fwd));
        if (r === startR && !b[sq + 2*dir]) out.push(mv(sq, sq + 2*dir, 0, 1));
      }
      for (const df of [-1, 1]){
        const nf = f + df;
        if (nf < 0 || nf > 7) continue;
        const ts = (r + (turn === WHITE ? -1 : 1)) * 8 + nf;
        const tp = b[ts];
        if (tp && pieceColor(tp) === enemy){
          if ((ts >> 3) === promoR)
            out.push(mv(sq,ts,5), mv(sq,ts,4), mv(sq,ts,3), mv(sq,ts,2));
          else out.push(mv(sq, ts, 0, 0, tp));
        } else if (!tp && ts === s.ep){
          out.push(mv(sq, ts, 0, 2, turn === WHITE ? BP : WP));
        }
      }
    } else if (t === 2){ // knight
      for (const n of KN_N[sq]){
        const tp = b[n];
        if (!tp) out.push(mv(sq, n));
        else if (pieceColor(tp) === enemy) out.push(mv(sq, n, 0, 0, tp));
      }
    } else if (t === 3 || t === 4 || t === 5){ // sliders
      const dirs = t === 3 ? BISH_D : t === 4 ? ROOK_D : ALL_D;
      for (const [df, dr] of dirs){
        let nf = f + df, nr = r + dr;
        while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
          const n = nr*8+nf, tp = b[n];
          if (!tp) out.push(mv(sq, n));
          else { if (pieceColor(tp) === enemy) out.push(mv(sq, n, 0, 0, tp)); break; }
          nf += df; nr += dr;
        }
      }
    } else { // king
      for (const n of KING_N[sq]){
        const tp = b[n];
        if (!tp) out.push(mv(sq, n));
        else if (pieceColor(tp) === enemy) out.push(mv(sq, n, 0, 0, tp));
      }
      const c = s.castling;
      if (turn === WHITE){
        if (sq === 60 && (c & 1) && !b[61] && !b[62] && b[63] === WR &&
            !isSquareAttacked(b,60,BLACK) && !isSquareAttacked(b,61,BLACK) && !isSquareAttacked(b,62,BLACK))
          out.push(mv(60, 62, 0, 4));
        if (sq === 60 && (c & 2) && !b[57] && !b[58] && !b[59] && b[56] === WR &&
            !isSquareAttacked(b,60,BLACK) && !isSquareAttacked(b,58,BLACK) && !isSquareAttacked(b,59,BLACK))
          out.push(mv(60, 58, 0, 8));
      } else {
        if (sq === 4 && (c & 4) && !b[5] && !b[6] && b[7] === BR &&
            !isSquareAttacked(b,4,WHITE) && !isSquareAttacked(b,5,WHITE) && !isSquareAttacked(b,6,WHITE))
          out.push(mv(4, 6, 0, 4));
        if (sq === 4 && (c & 8) && !b[1] && !b[2] && !b[3] && b[0] === BR &&
            !isSquareAttacked(b,4,WHITE) && !isSquareAttacked(b,2,WHITE) && !isSquareAttacked(b,3,WHITE))
          out.push(mv(4, 2, 0, 8));
      }
    }
  }
  return out;
}
function updCastle(c, m, piece){
  if (piece === WK) c &= ~3;
  if (piece === BK) c &= ~12;
  if (m.from === 63 || m.to === 63) c &= ~1;   // h1
  if (m.from === 56 || m.to === 56) c &= ~2;   // a1
  if (m.from === 7  || m.to === 7)  c &= ~4;   // h8
  if (m.from === 0  || m.to === 0)  c &= ~8;   // a8
  return c;
}
function makeMove(s, m){
  const b = s.board;
  const piece = b[m.from];
  const rec = { m, piece, cap: m.cap || b[m.to], ep: s.ep, castle: s.castling, half: s.half, full: s.full };
  b[m.from] = EMPTY;
  if (m.flag & 2) b[m.to + (s.turn === WHITE ? 8 : -8)] = EMPTY;          // en passant capture
  if (m.flag & 12){                                                        // castle: move rook
    if (m.flag & 4){ b[m.to-1] = b[m.to+1]; b[m.to+1] = EMPTY; }
    else           { b[m.to+1] = b[m.to-2]; b[m.to-2] = EMPTY; }
  }
  b[m.to] = m.promo ? m.promo + (s.turn === WHITE ? 0 : 6) : piece;
  s.ep = (m.flag & 1) ? (m.from + (s.turn === WHITE ? -8 : 8)) : -1;
  s.castling = updCastle(s.castling, m, piece);
  s.half = (piece === WP || piece === BP || rec.cap) ? 0 : s.half + 1;
  if (s.turn === BLACK) s.full++;
  s.turn = -s.turn;
  s.history.push(rec);
  return rec;
}
function unmakeMove(s, rec){
  const m = rec.m, b = s.board;
  s.turn = -s.turn;
  const white = s.turn === WHITE;
  b[m.to] = EMPTY;
  if (m.flag & 2) b[m.to + (white ? 8 : -8)] = rec.cap;
  else if (m.flag & 12){
    if (m.flag & 4){ b[m.to+1] = b[m.to-1]; b[m.to-1] = EMPTY; }
    else           { b[m.to-2] = b[m.to+1]; b[m.to+1] = EMPTY; }
  }
  else if (rec.cap) b[m.to] = rec.cap;
  b[m.from] = rec.piece;
  s.ep = rec.ep; s.castling = rec.castle; s.half = rec.half; s.full = rec.full;
  s.history.pop();
}
// Generator-based validation: a move is legal iff, after making it,
// the mover's king is not attacked. (Castling transit checks are done in genPseudo.)
function legalMoves(s){
  const pseudo = genPseudo(s, []);
  const res = [];
  for (const m of pseudo){
    const rec = makeMove(s, m);
    const k = findKing(s.board, -s.turn);
    if (!isSquareAttacked(s.board, k, s.turn)) res.push(m);
    unmakeMove(s, rec);
  }
  return res;
}

/* ================================================================
   5. GAME STATUS (check / mate / stalemate / draws)
   ================================================================ */
function insufficientMaterial(b){
  const cnt = { w: { n: 0, b: 0 }, b: { n: 0, b: 0 } };
  const bishops = [];
  for (let i = 0; i < 64; i++){
    const p = b[i];
    if (!p || pieceType(p) === 6) continue;
    const side = p < 7 ? 'w' : 'b', t = pieceType(p);
    if (t === 2) cnt[side].n++;
    else if (t === 3){ cnt[side].b++; bishops.push(i); }
    else return false;                       // pawn / rook / queen present
  }
  const w = cnt.w.n + cnt.w.b, bl = cnt.b.n + cnt.b.b;
  if (w === 0 && bl === 0) return true;       // K vs K
  if (w === 0) return bl === 1;               // K vs K+B or K+N
  if (bl === 0) return w === 1;               // K+B or K+N vs K
  if (cnt.w.n || cnt.b.n) return false;        // a knight on both sides
  if (bishops.length === 2){                   // K+B vs K+B: draw only same colour
    const c = i => ((i & 7) + (i >> 3)) & 1;
    return c(bishops[0]) === c(bishops[1]);
  }
  return false;
}
function getStatus(s){
  const legal = legalMoves(s);
  const check = inCheck(s, s.turn);
  if (!legal.length)
    return { over: true, type: check ? 'mate' : 'stalemate',
             result: check ? (s.turn === WHITE ? '0-1' : '1-0') : '1/2-1/2', check, legal };
  if (s.half >= CFG.FIFTY_MOVE_LIMIT)
    return { over: true, type: 'fifty', result: '1/2-1/2', check, legal };
  if (insufficientMaterial(s.board))
    return { over: true, type: 'material', result: '1/2-1/2', check, legal };
  return { over: false, check, legal };
}

/* ================================================================
   6. ALGEBRAIC NOTATION (SAN)
   ================================================================ */
function sanForMove(s, m){
  let san;
  if (m.flag & 12) san = (m.flag & 4) ? 'O-O' : 'O-O-O';
  else {
    const t = pieceType(s.board[m.from]);
    san = '';
    if (t !== 1) san += 'NBRQK'[t-2];
    else if (t === 1 && m.cap) san += 'abcdefgh'[m.from & 7];
    if (t !== 1 && t !== 6){
      const others = legalMoves(s).filter(x => x.from !== m.from && x.to === m.to && pieceType(s.board[x.from]) === t);
      if (others.length){
        const sameFile = others.some(x => (x.from & 7) === (m.from & 7));
        const sameRank = others.some(x => (x.from >> 3) === (m.from >> 3));
        if (!sameFile) san += 'abcdefgh'[m.from & 7];
        else if (!sameRank) san += String(8 - (m.from >> 3));
        else san += 'abcdefgh'[m.from & 7] + String(8 - (m.from >> 3));
      }
    }
    if (m.cap) san += 'x';
    san += sqName(m.to);
    if (m.promo) san += '=' + 'NBRQ'[m.promo-2];
    if (m.flag & 2) san += ' e.p.';
  }
  const s2 = cloneState(s);
  makeMove(s2, m);
  if (inCheck(s2, -s.turn)) san += legalMoves(s2).length ? '+' : '#';
  return san;
}
</script>
<script>
'use strict';
/* ================================================================
   7. AI: EVALUATION + MINIMAX (ALPHA-BETA, QUIESCENCE)
   ================================================================ */
// Piece-square tables, written from White's perspective, index 0 = a8.
// (Stored mirrored for Black via table[63 - sq].)
const PST = {
  1: [ 0,  0,  0,  0,  0,  0,  0,  0,   // white pawns (rank 8 empty)
       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 ],
  2: [-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],
  3: [-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],
  4: [  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 ],
  5: [ -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],
  6: [ -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 ]
};
function evaluate(s){
  const b = s.board;
  let score = 0;
  for (let sq = 0; sq < 64; sq++){
    const p = b[sq];
    if (!p) continue;
    const t = pieceType(p);
    let v = CFG.PIECE_VALUES[t] + PST[t][p < 7 ? sq : 63 - sq];
    if (t === 6) v = CFG.PIECE_VALUES[6];               // king: no PST in material sum
    score += p < 7 ? v : -v;
  }
  return s.turn === WHITE ? score : -score;             // side-to-move perspective
}
function orderMoves(s, moves){
  const b = s.board;
  const key = m => {
    let v = 0;
    if (m.promo) v += 900;
    if (m.cap) v += 100 * CFG.PIECE_VALUES[pieceType(m.cap)] - CFG.PIECE_VALUES[pieceType(b[m.from])];
    if (m.flag & 12) v += 50;
    return v;
  };
  moves.sort((a, b2) => key(b2) - key(a));
}
const YIELD = Symbol('yield');
function tick(budget){ if (budget && --budget.left <= 0) throw YIELD; }
function quiesce(s, alpha, beta, depth, budget){
  const stand = evaluate(s);
  if (stand >= beta) return stand;
  if (stand > alpha) alpha = stand;
  if (depth <= 0) return alpha;
  const caps = legalMoves(s).filter(m => m.cap || m.promo);
  orderMoves(s, caps);
  for (const m of caps){
    makeMove(s, m);
    let sc;
    try { sc = -quiesce(s, -beta, -alpha, depth - 1, budget); }
    finally { unmakeMove(s, s.history[s.history.length - 1]); }
    if (sc >= beta) return sc;
    if (sc > alpha) alpha = sc;
  }
  return alpha;
}
function search(s, depth, alpha, beta, budget){
  tick(budget);
  if (depth <= 0) return quiesce(s, alpha, beta, CFG.QUIESCENCE_DEPTH, budget);
  const moves = legalMoves(s);
  if (!moves.length){
    return inCheck(s, s.turn)
      ? -CFG.MATE_SCORE + s.history.length            // mated: prefer later mate
      : 0;
  }
  orderMoves(s, moves);
  let best = -Infinity;
  for (const m of moves){
    makeMove(s, m);
    let sc;
    try { sc = -search(s, depth - 1, -beta, -alpha, budget); }
    finally { unmakeMove(s, s.history[s.history.length - 1]); }
    if (sc > best) best = sc;
    if (best > alpha) alpha = best;
    if (alpha >= beta) break;
  }
  return best;
}
let aiToken = 0;
// Async, chunked search so the UI never freezes: the root loop yields
// every YIELD_EVERY_NODES nodes; try/finally guarantees make/unmake balance.
function aiChooseMove(depth, onDone){
  const s = cloneState(gameApp.state);
  const token = ++aiToken;
  const moves = legalMoves(s);
  if (!moves.length){ onDone(token, null); return; }
  if (moves.length === 1){ onDone(token, moves[0]); return; }
  orderMoves(s, moves);
  const budget = { left: CFG.YIELD_EVERY_NODES };
  let best = null, alpha = -Infinity, idx = 0;
  const step = () => {
    try {
      while (idx < moves.length){
        const m = moves[idx++];
        makeMove(s, m);
        let sc;
        try { sc = -search(s, depth - 1, -Infinity, -alpha, budget); }
        finally { unmakeMove(s, s.history[s.history.length - 1]); }
        if (sc > alpha){ alpha = sc; best = m; }
      }
      onDone(token, best);
    } catch(err){
      if (err === YIELD){
        budget.left = CFG.YIELD_EVERY_NODES;   // refill for the next chunk
        setTimeout(step, CFG.AI_CHUNK_MS);
      } else onDone(token, null);
    }
  };
  setTimeout(step, 30);
}

/* ================================================================
   8. SOUND (Web Audio, synthesized)
   ================================================================ */
const Sound = {
  ctx: null, muted: false,
  ensure(){
    if (!this.ctx){
      try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); }
      catch(e){ this.ctx = null; }
    }
    if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
    return this.ctx;
  },
  tone(freq, dur, type, gain, when = 0){
    const ctx = this.ensure();
    if (!ctx || this.muted) return;
    const t = ctx.currentTime + when;
    const o = ctx.createOscillator(), g = ctx.createGain();
    o.type = type; o.frequency.value = freq;
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(ctx.destination);
    o.start(t); o.stop(t + dur + 0.02);
  },
  play(name){
    if (this.muted) return;
    if (!this.ensure()) return;
    switch(name){
      case 'move':    this.tone(220, .09, 'triangle', .25); break;
      case 'capture': this.tone(150, .12, 'square', .22); this.tone(98, .1, 'triangle', .18, .02); break;
      case 'check':   this.tone(660, .14, 'sine', .2); this.tone(880, .16, 'sine', .16, .1); break;
      case 'illegal': this.tone(110, .12, 'sawtooth', .12); break;
      case 'end':     this.tone(392, .12, 'sine', .2); this.tone(523, .12, 'sine', .2, .12); this.tone(659, .2, 'sine', .2, .24); break;
    }
  }
};

/* ================================================================
   9. UI: BOARD RENDERING, INTERACTION, PANEL
   ================================================================ */
const boardEl = document.getElementById('board');
const squares = [];
for (let i = 0; i < 64; i++){
  const d = document.createElement('div');
  d.className = 'sq ' + (((i >> 3) + (i & 7)) % 2 ? 'dark' : 'light');
  d.dataset.sq = i;
  const pc = document.createElement('span');
  pc.className = 'piece';
  d.appendChild(pc);
  d._rl = document.createElement('i'); d._rl.className = 'coord rank'; d.appendChild(d._rl);
  d._fl = document.createElement('i'); d._fl.className = 'coord file'; d.appendChild(d._fl);
  boardEl.appendChild(d); // final visual order is set by render() per orientation
  squares[i] = d;
}
// (squares are re-ordered every render according to orientation)
let orientation = WHITE;   // which colour is on the bottom
let selected = -1;
let legalCache = [];
let lastMove = null;
let checkSq = -1;
let overInfo = null;
let drag = null;           // { from, piece, el, ghost, over }

function render(){
  const s = gameApp.state;
  // reorder square elements to match orientation (overlays ride along at the end)
  const frame = boardEl;
  for (let vi = 0; vi < 64; vi++){
    const el = squares[orientation === WHITE ? vi : 63 - vi];
    const prev = frame.children[vi];
    if (prev !== el) frame.insertBefore(el, prev);
  }
  // orientation-aware coordinate labels (lichess-style: ranks on left col, files on bottom row)
  for (let vi = 0; vi < 64; vi++){
    const el = squares[orientation === WHITE ? vi : 63 - vi];
    const col = vi & 7, row = vi >> 3;
    el._rl.style.display = col === 0 ? '' : 'none';
    if (col === 0) el._rl.textContent = String(orientation === WHITE ? 8 - row : row + 1);
    el._fl.style.display = row === 7 ? '' : 'none';
    if (row === 7) el._fl.textContent = 'abcdefgh'[orientation === WHITE ? col : 7 - col];
  }
  // pieces & highlights
  const inCheckNow = overInfo ? overInfo.check : inCheck(s, s.turn);
  checkSq = inCheckNow ? findKing(s.board, s.turn) : -1;
  for (let i = 0; i < 64; i++){
    const el = squares[i];
    const pc = el.firstChild;
    const p = s.board[i];
    if (p){ pc.textContent = GLYPH[p]; pc.className = 'piece ' + (p < 7 ? 'wp' : 'bp'); }
    else { pc.textContent = ''; pc.className = 'piece'; }
    el.classList.toggle('last', !!lastMove && (i === lastMove.from || i === lastMove.to));
    el.classList.toggle('incheck', i === checkSq);
    el.classList.toggle('selected', i === selected);
    el.classList.toggle('dragover', !!(drag && drag.over === i));
    for (const h of el.querySelectorAll('.hint')) h.remove();
  }
  if (selected >= 0){
    const dests = new Map();
    for (const m of legalCache) if (m.from === selected) dests.set(m.to, m);
    for (const [to, m] of dests){
      const h = document.createElement('span');
      h.className = 'hint' + (m.cap || m.flag & 2 ? ' cap' : '');
      squares[to].appendChild(h);
    }
  }
  renderTrays();
  renderStatus();
  renderMoves();
}
function materialOf(b, color){
  let m = 0;
  for (let i = 0; i < 64; i++){
    const p = b[i];
    if (p && pieceColor(p) === color) m += CFG.PIECE_VALUES[pieceType(p)];
  }
  return m;
}
function renderTrays(){
  const b = gameApp.state.board;
  const topColor = orientation === WHITE ? BLACK : WHITE;   // tray above board shows the far side's captures
  const botColor = -topColor;
  const farTray = document.getElementById('tray-top');
  const nearTray = document.getElementById('tray-bottom');
  // tray holds pieces CAPTURED BY the side whose tray it is
  const fill = (el, capturer) => {
    // captured = initial material - current (per type)
    const init = { 1:8, 2:2, 3:2, 4:2, 5:1 };
    const cur = { 1:0, 2:0, 3:0, 4:0, 5:0 };
    for (let i = 0; i < 64; i++){
      const p = b[i];
      if (p && pieceColor(p) === -capturer) cur[pieceType(p)] = (cur[pieceType(p)]||0) + 1;
    }
    let html = '', adv = 0;
    for (const t of [5,4,3,2,1]){
      const n = init[t] - cur[t];
      for (let k = 0; k < n; k++) html += '<span class="gs ' + (-capturer < 0 ? 'bp' : 'wp') + '">' + GLYPH[-capturer < 0 ? t+6 : t] + '</span>';
      adv += n * (CFG.PIECE_VALUES[t] - (t === 1 ? 100 : 0)); // rough: pawns worth 100
    }
    const diff = materialOf(b, capturer) - materialOf(b, -capturer);
    if (diff > 0) html += '<span class="mat">+' + Math.round(diff / 100) + '</span>';
    el.innerHTML = html;
  };
  fill(farTray, topColor);
  fill(nearTray, botColor);
}
function renderStatus(){
  const s = gameApp.state;
  const main = document.getElementById('status-main');
  const sub = document.getElementById('status-sub');
  const evalEl = document.getElementById('eval-line');
  if (overInfo){
    main.innerHTML = '<span class="dot ' + (s.turn === WHITE ? 'w' : 'b') + '"></span> Game over';
    sub.innerHTML = overInfo.type === 'mate' ? 'Checkmate' :
                    overInfo.type === 'stalemate' ? 'Stalemate' :
                    overInfo.type === 'fifty' ? 'Draw — 50-move rule' : 'Draw — insufficient material';
    evalEl.textContent = 'Result: ' + overInfo.result;
    return;
  }
  const who = s.turn === WHITE ? 'White' : 'Black';
  const role = gameApp.mode === 'both' ? 'AI' : (s.turn === gameApp.color ? 'You' : 'AI');
  main.innerHTML = '<span class="dot ' + (s.turn === WHITE ? 'w' : 'b') + '"></span> ' +
    '<span>' + who + (role === 'You' ? ' to move' : ' (AI)') + '</span>' +
    (gameApp.aiPending ? '<span class="thinking">thinking\u2026</span>' : '');
  sub.innerHTML = overInfo ? '' : (inCheck(s, s.turn) ? '<span class="check">Check!</span>' : '');
  const ev = evaluate(s);
  evalEl.textContent = 'Eval: ' + (ev >= 0 ? '+' : '') + (ev / 100).toFixed(2) +
    (ev >= CFG.MATE_SCORE - 1000 ? ' (mate)' : ev <= -(CFG.MATE_SCORE - 1000) ? ' (mated)' : '');
}
function renderMoves(){
  const el = document.getElementById('moves');
  const h = gameApp.state.history;
  let html = '';
  if (!h.length) html = '<div class="mv-empty">No moves yet — White to play.</div>';
  for (let i = 0; i < h.length; i += 2){
    const n = i / 2 + 1;
    const w = h[i].san, b2 = h[i+1] ? h[i+1].san : '';
    const wc = i === h.length - 1 ? ' mv cur' : '';
    const bc = i+1 === h.length - 1 ? ' mv cur' : '';
    html += '<div class="mv-row"><span class="mv-num">' + n + '.</span><span class="mv' + wc + '">' + w + '</span><span class="mv' + bc + '">' + b2 + '</span></div>';
  }
  el.innerHTML = html;
  el.scrollTop = el.scrollHeight;
}
function showPromoPicker(m){
  const promo = document.getElementById('promo');
  const rect = squares[m.to].getBoundingClientRect();
  const brect = boardEl.getBoundingClientRect();
  const cell = brect.width / 8;
  let left = rect.left - brect.left + cell / 2 - 62;
  let top = rect.top - brect.top + cell / 2 - 62;
  left = Math.max(8, Math.min(left, brect.width - 128));
  top = Math.max(8, Math.min(top, brect.height - 128));
  promo.style.left = left + 'px';
  promo.style.top = top + 'px';
  promo.innerHTML = '';
  const order = [5, 4, 3, 2];
  for (const t of order){
    const btn = document.createElement('button');
    const p = t + (gameApp.state.turn === WHITE ? 0 : 6);
    btn.innerHTML = '<span class="' + (p < 7 ? 'wp' : 'bp') + '">' + GLYPH[p] + '</span>';
    btn.onclick = () => {
      promo.classList.add('hidden');
      gameApp.doMove({ from: m.from, to: m.to, promo: t, flag: m.flag, cap: m.cap });
    };
    promo.appendChild(btn);
  }
  promo.classList.remove('hidden');
}
function hidePromoPicker(){ document.getElementById('promo').classList.add('hidden'); }
function showGameOver(){
  const el = document.getElementById('over');
  const t = document.getElementById('over-title');
  const s2 = document.getElementById('over-sub');
  const s = gameApp.state;
  const winner = s.turn === WHITE ? 'Black' : 'White';
  if (overInfo.type === 'mate'){
    t.textContent = 'Checkmate';
    s2.textContent = winner + ' wins — ' + overInfo.result +
      (gameApp.mode === 'both' ? '' : (winner === (gameApp.color === WHITE ? 'White' : 'Black') ? '  (you win!)' : '  (AI wins)'));
  } else if (overInfo.type === 'stalemate'){
    t.textContent = 'Stalemate';
    s2.textContent = 'Draw — ' + overInfo.result + ' (no legal moves, not in check)';
  } else if (overInfo.type === 'fifty'){
    t.textContent = 'Draw';
    s2.textContent = 'Fifty-move rule — ' + overInfo.result;
  } else {
    t.textContent = 'Draw';
    s2.textContent = 'Insufficient material — ' + overInfo.result;
  }
  el.classList.remove('hidden');
}
function hideGameOver(){ document.getElementById('over').classList.add('hidden'); }

// ---- interaction: click & drag ----
function evToSq(e){
  const rect = boardEl.getBoundingClientRect();
  const x = e.clientX - rect.left, y = e.clientY - rect.top;
  if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) return -1;
  const vi = Math.floor(x / (rect.width / 8)) + Math.floor(y / (rect.height / 8)) * 8;
  return orientation === WHITE ? vi : 63 - vi;
}
boardEl.addEventListener('pointerdown', e => {
  if (overInfo || gameApp.aiPending) return;
  if (e.target.closest('#promo') || e.target.closest('#over')) return;
  hidePromoPicker();
  const sq = evToSq(e);
  if (sq < 0) return;
  const p = gameApp.state.board[sq];
  if (selected >= 0 && legalCache.some(m => m.from === selected && m.to === sq)){
    const m = legalCache.find(m => m.from === selected && m.to === sq);
    if (m.promo){ showPromoPicker(m); return; }
    clearSelection();
    gameApp.doMove(m);
    return;
  }
  if (p && pieceColor(p) === gameApp.state.turn){
    if (gameApp.mode !== 'both' && gameApp.state.turn !== gameApp.color) return;
    selected = sq;
    legalCache = legalMoves(gameApp.state);
    render();
  } else {
    clearSelection();
  }
  // start drag
  if (p && pieceColor(p) === gameApp.state.turn && (gameApp.mode === 'both' || gameApp.state.turn === gameApp.color)){
    if (selected < 0){ selected = sq; legalCache = legalMoves(gameApp.state); }
    drag = { from: sq, piece: p, el: squares[sq], moved: false, over: sq };
    e.preventDefault();
  }
});
boardEl.addEventListener('pointermove', e => {
  if (!drag) return;
  drag.moved = true;
  // ghost follows pointer
  const g = document.getElementById('ghost');
  g.textContent = GLYPH[drag.piece];
  g.className = drag.piece < 7 ? 'wp' : 'bp';
  g.style.left = e.clientX + 'px';
  g.style.top = e.clientY + 'px';
  g.style.fontSize = (boardEl.getBoundingClientRect().width / 8 * .74) + 'px';
  g.classList.remove('hidden');
  squares[drag.from].firstChild.classList.add('dragging');
  const sq = evToSq(e);
  if (drag.over !== sq){ drag.over = sq; render(); }
});
function endDrag(e){
  if (!drag) return;
  const d = drag; drag = null;
  document.getElementById('ghost').classList.add('hidden');
  squares[d.from].firstChild.classList.remove('dragging');
  const sq = e ? evToSq(e) : -1;
  if (sq >= 0 && d.moved){
    const m = legalCache.find(m => m.from === d.from && m.to === sq);
    if (m){
      clearSelection();
      if (m.promo) showPromoPicker(m);
      else gameApp.doMove(m);
      return;
    }
  }
  render();
}
boardEl.addEventListener('pointerup', endDrag);
boardEl.addEventListener('pointercancel', endDrag);
boardEl.addEventListener('pointerleave', e => { if (drag && drag.moved) endDrag(null); });
window.addEventListener('keydown', e => {
  if (e.key === 'Escape'){ clearSelection(); hidePromoPicker(); }
});
function clearSelection(){ selected = -1; legalCache = []; }

/* ================================================================
   10. GAME CONTROLLER (moves, AI scheduling, undo, persistence)
   ================================================================ */
const gameApp = {
  state: null,
  color: WHITE,      // human colour (ignored in 'both' mode)
  mode: 'white',     // 'white' | 'black' | 'both'
  difficulty: 'club',
  aiPending: false,

  newGame(color, mode, difficulty){
    this.stopAI();
    this.state = parseFen(CFG.START_FEN);
    this.color = color;
    this.mode = mode;
    this.difficulty = difficulty || 'club';
    lastMove = null; overInfo = null; selected = -1; legalCache = [];
    orientation = color === BLACK ? BLACK : WHITE;
    hidePromoPicker(); hideGameOver();
    render();
    this.save();
    this.maybeAI();
  },

  doMove(m, san){
    const s = this.state;
    if (!s) return null;
    if (overInfo) return null;
    const legal = legalMoves(s);
    const exact = legal.find(x => x.from === m.from && x.to === m.to && x.promo === (m.promo || 0));
    if (!exact) return null;                        // generator-based: only generated moves pass
    const s2 = san || sanForMove(s, exact);
    const rec = makeMove(s, exact);
    rec.san = s2;
    lastMove = { from: exact.from, to: exact.to };
    selected = -1; legalCache = [];
    hidePromoPicker();
    const st = getStatus(s);
    const isCap = !!rec.cap;
    Sound.play(st.over && st.type === 'mate' ? 'end' : st.check ? 'check' : isCap ? 'capture' : 'move');
    if (st.over){
      overInfo = st;
      render();
      showGameOver();
      this.save();
      return rec;
    }
    render();
    this.save();
    this.maybeAI();
    return rec;
  },

  maybeAI(){
    if (overInfo || this.aiPending) return;
    const s = this.state;
    const aiTurn = this.mode === 'both' || s.turn !== this.color;
    if (!aiTurn) return;
    this.aiPending = true;
    render();
    const depth = CFG.AI_DEPTHS[this.difficulty] || 2;
    aiChooseMove(depth, (token, m) => {
      if (token !== aiToken) return;   // superseded by a newer search / cancelled
      this.aiPending = false;
      if (!m){ render(); return; }
      const st0 = getStatus(this.state);
      if (st0.over) return;
      // re-validate before playing (state could have been undone meanwhile)
      const ok = legalMoves(this.state).some(x => x.from === m.from && x.to === m.to && x.promo === (m.promo || 0));
      if (ok) this.doMove(m);
      else render();
    });
  },
  stopAI(){ aiToken++; this.aiPending = false; },

  undo(){
    this.stopAI();
    hidePromoPicker();
    const h = this.state.history;
    if (!h.length) return;
    // take back the AI move and the player's move (i.e. 2 plies; 1 if player is Black and only AI moved)
    let n = 2;
    if (this.mode !== 'both' && this.color === BLACK) n = 1;
    while (n-- > 0 && h.length){
      unmakeMove(this.state, h[h.length - 1]);
    }
    lastMove = h.length ? { from: h[h.length-1].m.from, to: h[h.length-1].m.to } : null;
    overInfo = null;
    hideGameOver();
    selected = -1; legalCache = [];
    render();
    this.save();
    this.maybeAI();
  },

  save(){
    try {
      localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
        fen: fenOf(this.state),
        history: this.state.history,
        color: this.color, mode: this.mode, difficulty: this.difficulty
      }));
    } catch(e){}
  },
  restore(){
    try {
      const raw = localStorage.getItem(CFG.STORAGE_KEY);
      if (!raw) return false;
      const d = JSON.parse(raw);
      if (!d.fen) return false;
      this.state = parseFen(d.fen);
      this.state.history = (d.history || []).map(h => ({
        m: h.m, san: h.san, piece: h.piece, cap: h.cap, ep: h.ep,
        castle: h.castle, half: h.half, full: h.full
      }));
      this.color = d.color || WHITE;
      this.mode = d.mode || 'white';
      this.difficulty = d.difficulty || 'club';
      orientation = this.color === BLACK ? BLACK : WHITE;
      lastMove = this.state.history.length
        ? { from: this.state.history[this.state.history.length-1].m.from, to: this.state.history[this.state.history.length-1].m.to }
        : null;
      const st = getStatus(this.state);
      overInfo = st.over ? st : null;
      if (overInfo) showGameOver();
      return true;
    } catch(e){ return false; }
  }
};

/* ================================================================
   11. WIRING: CONTROLS, PERSISTENCE, DEBUG, BOOT
   ================================================================ */
function setSeg(el, val){
  for (const b of el.querySelectorAll('button')) b.classList.toggle('on', b.dataset.v === val);
}
function wireControls(){
  const segDiff = document.getElementById('seg-diff');
  const segColor = document.getElementById('seg-color');
  segDiff.addEventListener('click', e => {
    const b = e.target.closest('button'); if (!b) return;
    gameApp.difficulty = b.dataset.v;
    setSeg(segDiff, b.dataset.v);
    gameApp.save();
    if (gameApp.aiPending || (gameApp.mode === 'both' || gameApp.state.turn !== gameApp.color)) {
      // restart the AI's current thought with the new depth
      gameApp.stopAI(); gameApp.aiPending = false; gameApp.maybeAI();
    }
  });
  segColor.addEventListener('click', e => {
    const b = e.target.closest('button'); if (!b) return;
    const v = b.dataset.v;
    if (v === 'both') gameApp.newGame(WHITE, 'both', gameApp.difficulty);
    else gameApp.newGame(v === 'white' ? WHITE : BLACK, v, gameApp.difficulty);
    setSeg(segColor, v);
  });
  document.getElementById('btn-undo').addEventListener('click', () => gameApp.undo());
  document.getElementById('btn-flip').addEventListener('click', () => {
    orientation = -orientation;
    render();
  });
  const sndBtn = document.getElementById('btn-sound');
  sndBtn.addEventListener('click', () => {
    Sound.muted = !Sound.muted;
    sndBtn.textContent = 'Sound: ' + (Sound.muted ? 'Off' : 'On');
    if (!Sound.muted) Sound.play('move');
  });
  document.getElementById('btn-new').addEventListener('click', () => {
    const cur = segColor.querySelector('button.on');
    const v = cur ? cur.dataset.v : 'white';
    if (v === 'both') gameApp.newGame(WHITE, 'both', gameApp.difficulty);
    else gameApp.newGame(v === 'white' ? WHITE : BLACK, v, gameApp.difficulty);
  });
  document.getElementById('over-new').addEventListener('click', () => {
    const cur = segColor.querySelector('button.on');
    const v = cur ? cur.dataset.v : 'white';
    if (v === 'both') gameApp.newGame(WHITE, 'both', gameApp.difficulty);
    else gameApp.newGame(v === 'white' ? WHITE : BLACK, v, gameApp.difficulty);
  });
  // sync segment states
  setSeg(segDiff, gameApp.difficulty);
  setSeg(segColor, gameApp.mode === 'both' ? 'both' : (gameApp.color === WHITE ? 'white' : 'black'));
  sndBtn.textContent = 'Sound: ' + (Sound.muted ? 'Off' : 'On');
}
// unlock audio on first user gesture
window.addEventListener('pointerdown', () => Sound.ensure(), { once: true });

/* ---- minimal debug interface (headless probing) ---- */
window.game = {
  fen: () => fenOf(gameApp.state),
  move: (uci) => {
    const m = { from: nameToSq(uci.slice(0,2)), to: nameToSq(uci.slice(2,4)),
                promo: uci[4] ? +('NBRQ'.indexOf(uci[4].toUpperCase()) + 2) : 0, flag: 0, cap: 0 };
    const rec = gameApp.doMove(m);
    return rec ? rec.san : null;
  },
  legalMoves: () => legalMoves(gameApp.state).map(m => sqName(m.from) + sqName(m.to) + (m.promo ? 'NBRQ'[m.promo-2] : '')),
  status: () => { const st = getStatus(gameApp.state); return { over: st.over, type: st.type || null, check: st.check, legal: st.legal.length }; },
  setFen: (f) => {
    gameApp.stopAI();
    gameApp.state = parseFen(f);
    lastMove = null; overInfo = null; hidePromoPicker(); hideGameOver();
    render();
    gameApp.maybeAI();   // reschedule AI for the new position (if it's the AI's turn)
    return fenOf(gameApp.state);
  },
  undo: () => gameApp.undo(),
  newGame: (color, mode, diff) => gameApp.newGame(color || WHITE, mode || 'white', diff || 'club'),
  eval: () => evaluate(gameApp.state),
  state: null // assigned below
};
window.game.state = null;

function boot(){
  // mirror palette into CSS
  const st = document.documentElement.style;
  st.setProperty('--sq-light', CFG.COLORS.SQ_LIGHT);
  st.setProperty('--sq-dark', CFG.COLORS.SQ_DARK);
  st.setProperty('--gold', CFG.COLORS.GOLD);
  const restored = gameApp.restore();
  if (!restored) gameApp.newGame(WHITE, 'white', 'club');
  else gameApp.maybeAI();   // resume the AI if the restored position is its turn
  window.game.state = gameApp.state;
  window.game.__sync = () => { window.game.state = gameApp.state; };
  wireControls();
  render();
}
// keep window.game.state in sync by wrapping save/newGame
const _save = gameApp.save.bind(gameApp);
gameApp.save = function(){ _save(); window.game.state = this.state; };
const _newGame = gameApp.newGame.bind(gameApp);
gameApp.newGame = function(c, m, d){ _newGame(c, m, d); window.game.state = this.state; };
const _restore = gameApp.restore.bind(gameApp);
gameApp.restore = function(){ const r = _restore(); window.game.state = this.state; return r; };
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"7797c285-379c-43af-a1e2-4cb7544609cd","tokensIn":9552095,"tokensOut":244327,"tokensTotal":9796422,"cost":0,"turns":68,"toolCalls":84,"failedToolCalls":4,"timestamp":"2026-08-31T17:56:51.379Z"} -->