← Chess results

Chess

Claude haiku 5.5 · full

Initial view of Chess

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 5abd3590fa
# Task: Build a Chess Game with AI Opponent

Build fully rule-complete chess against a computer opponent (vanilla JS, no external assets, no CDN, no libraries). The top priority is **rules correctness** — every legal move must be accepted, every illegal move rejected, including all the edge cases.

## Core Specification

**Rules Engine (the heart of the app — no shortcuts)**
- Standard 8×8 board, correct initial position, White moves first
- Full move generation for all pieces, including **all special rules**:
  - Castling (both sides), forbidden while in check, through check, or after king/rook has moved
  - En passant — only on the immediately following move
  - Pawn promotion (to Q/R/B/N, via an under-promotion picker UI)
- A move that leaves your own king in check is illegal and must be rejected
- Check, checkmate, and stalemate detection; also draw by insufficient material (K vs K, K+B vs K, K+N vs K) and 50-move rule
- Move validation is **generator-based**: the UI only offers moves produced by the legal-move generator, so illegal states are unreachable

**Interaction**
- Click-to-move (select piece → legal destination squares highlighted with dots; captures ringed) **and** drag-and-drop — both must work
- Last-move highlight; check shown on the king's square (red glow)
- Flip-board button; captured-pieces trays; move list in algebraic notation (e.g. `Nf3`, `O-O`, `exd6 e.p.`, `Qxd8+`, `#` on mate), auto-scrolling
- Undo (takes back both the player and AI move) and New Game
- Sound effects via Web Audio (move, capture, check) with a mute toggle

**AI Opponent**
- Minimax with alpha-beta pruning, iterative-friendly fixed depth chosen for responsiveness (target ≤ ~1s per move), plus quiescence-lite or move ordering (captures first) to avoid tactical blindness
- Evaluation: material + piece-square tables at minimum
- Player chooses White or Black (and optionally plays both sides / analysis mode); the AI must never make an illegal move or freeze the UI — run the search chunked or in small time slices if needed
- Difficulty selector (depth 1 / 2 / 3)

**Persistence**
- Game state (board, turn, castling rights, en-passant square, move list) serializes to `localStorage` and restores on reload mid-game

## Visual Style
- Elegant, classic chess aesthetic: warm wood or slate board palette, crisp Unicode-piece glyphs (♞♟) or clean inline-SVG pieces, subtle square shading
- Board centered; side panel with move list, captured pieces, controls
- Promotion picker as a small overlay on the promotion square
- Clear turn indicator and game-over banner (checkmate/stalemate/draw, with the result stated)

## Technical Requirements
- Vanilla JS, with clear section comments
- Board representation and move generation cleanly separated from rendering (0x88, mailbox, or bitboards — your choice, but make it readable)
- Tunable constants block at the top (colors, AI depths, eval weights)
- Expose a minimal debug interface on `window` (e.g. `game.fen()`, `game.move('e2e4')`, `game.legalMoves()`) so the engine can be probed headlessly

## Quality Bar (must hit all)
- Castling, en passant, and promotion all work from the UI
- You cannot move into check, and pinned pieces cannot abandon their pin
- Checkmate and stalemate are correctly announced (Fool's mate and a simple back-rank mate both detected)
- The AI at depth 3 does not hang pieces trivially and never returns an illegal move
- A mid-game reload restores the exact position, side to move, castling rights, and en-passant state

## Self-Check Before Finishing
Verify with the browser tools, using the debug interface:
- Fool's mate: `f3 e5 g4 Qh4#` → checkmate announced, no further moves accepted
- Set up an en-passant opportunity and confirm the capture is generated exactly once (only on the immediately following move)
- Attempt a move that exposes the king → rejected by `legalMoves()`
- Play ~10 moves against the AI, reload, confirm identical state
- Position count sanity check: from the start position, `legalMoves()` returns 20

Inspect original source 45,737 bytes · SHA-256 59452f9ff207
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chess vs Computer</title>
<style>
:root{
  --light:#ecdab4; --dark:#9d6b43; --frame:#3b2616;
  --hl-last:rgba(255,226,90,.50); --hl-sel:rgba(90,170,255,.60);
  --dot:rgba(25,25,25,.38); --ring:rgba(25,25,25,.42); --check:#ff3b30;
  --panel:#27211d; --panel2:#332b25; --text:#f3e9d6; --muted:#b9a88c; --accent:#e0a94a;
  --wp:#fffbf2; --bp:#1b1410; --bs:min(82vh,580px);
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:14px;
  background:radial-gradient(ellipse at center,#4a3a2e 0%,#1d1612 75%);color:var(--text);
  font-family:Georgia,"Palatino Linotype",Palatino,serif}
.app{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap;justify-content:center}
.boardArea{position:relative;width:var(--bs);height:var(--bs);padding:0;background:var(--frame);
  border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.6),inset 0 0 0 2px #5a3b22}
.board{position:absolute;inset:10px;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);
  user-select:none;touch-action:none;border-radius:2px;overflow:hidden}
.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{background-image:linear-gradient(var(--hl-last),var(--hl-last))}
.sq.sel{background-image:linear-gradient(var(--hl-sel),var(--hl-sel))}
.sq.check{background:radial-gradient(circle,#ff5a4a 0%,#c4150c 45%,transparent 75%)!important}
.sq .coord{position:absolute;font-size:calc(var(--bs)*.024);font-weight:bold;opacity:.7;pointer-events:none;font-family:Arial,sans-serif}
.sq .coord.f{right:3px;bottom:1px} .sq .coord.r{left:3px;top:2px}
.sq.light .coord{color:var(--dark)} .sq.dark .coord{color:var(--light)}
.sq.dot::after{content:"";position:absolute;width:28%;height:28%;border-radius:50%;background:var(--dot);pointer-events:none}
.sq.ring::after{content:"";position:absolute;inset:6%;border-radius:50%;border:calc(var(--bs)*.012) solid var(--ring);pointer-events:none;box-sizing:border-box}
.pc{font-size:calc(var(--bs)*.088);line-height:1;position:relative;z-index:2;pointer-events:none;font-family:"Segoe UI Symbol","Noto Sans Symbols 2","DejaVu Sans",serif}
.pc.w{color:var(--wp);text-shadow:0 0 2px #000,0 0 2px #000,1px 1px 1px #000,-1px -1px 1px #000}
.pc.b{color:var(--bp);text-shadow:0 0 1px rgba(255,255,255,.35)}
.pc.dragging{opacity:.25}
.ghost{position:fixed;z-index:1000;pointer-events:none;transform:translate(-50%,-50%);font-size:calc(var(--bs)*.1);line-height:1;font-family:"Segoe UI Symbol","Noto Sans Symbols 2","DejaVu Sans",serif;filter:drop-shadow(0 6px 6px rgba(0,0,0,.5))}
.ghost.w{color:var(--wp);text-shadow:0 0 2px #000,1px 1px 1px #000,-1px -1px 1px #000}
.ghost.b{color:var(--bp)}
.promo{position:absolute;z-index:50;display:flex;flex-direction:column;background:var(--panel);border:2px solid var(--accent);border-radius:8px;padding:3px;box-shadow:0 8px 24px rgba(0,0,0,.7)}
.promo button{width:calc(var(--bs)/8);height:calc(var(--bs)/8);border:0;background:var(--light);cursor:pointer;border-radius:4px;margin:1px}
.promo button:hover{background:#fff3cc}
.promo button .pc{pointer-events:auto}
.promo button.b-ish{background:var(--dark)}
.promo-cancel{font-size:11px;color:var(--muted);background:none;border:0;cursor:pointer;padding:2px;font-family:Arial,sans-serif}
.banner{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:60;background:rgba(20,15,12,.92);border:2px solid var(--accent);
  padding:18px 26px;border-radius:10px;text-align:center;font-size:20px;display:none;box-shadow:0 10px 30px rgba(0,0,0,.7);max-width:80%}
.banner.show{display:block}
.banner small{display:block;font-size:14px;color:var(--muted);margin-top:6px}
.panel{width:310px;max-width:100%;background:linear-gradient(#2e2722,#221c18);border:1px solid #4b3a2c;border-radius:10px;padding:14px;
  display:flex;flex-direction:column;gap:12px;box-shadow:0 10px 30px rgba(0,0,0,.45);height:var(--bs);min-height:420px}
.title{font-size:20px;letter-spacing:.5px;color:var(--accent);margin:0}
.status{font-size:15px;padding:8px 10px;background:var(--panel2);border-radius:6px;display:flex;align-items:center;gap:8px;min-height:38px}
.dot-ind{width:14px;height:14px;border-radius:50%;border:1px solid #888;display:inline-block;flex:none}
.dot-ind.w{background:#fff}.dot-ind.b{background:#111}
.thinking{font-style:italic;color:var(--muted)}
.trays{display:flex;flex-direction:column;gap:4px;font-size:18px;min-height:28px}
.tray{display:flex;align-items:center;gap:2px;flex-wrap:wrap;min-height:24px}
.tray .lbl{font-size:12px;color:var(--muted);font-family:Arial,sans-serif;margin-right:6px;width:70px}
.tray .gl{line-height:1}
.tray .adv{font-size:12px;color:var(--accent);margin-left:6px;font-family:Arial,sans-serif}
.moves{flex:1;min-height:120px;overflow-y:auto;background:var(--panel2);border-radius:6px;padding:6px 8px;font-family:Consolas,"Courier New",monospace;font-size:14px}
.moves table{width:100%;border-collapse:collapse}
.moves td{padding:2px 4px}
.moves td.n{color:var(--muted);width:34px}
.moves td.m{cursor:default}
.moves tr.cur td.m{background:rgba(224,169,74,.25);border-radius:3px}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.controls button,.controls select{background:#3f342b;color:var(--text);border:1px solid #5a4835;border-radius:6px;padding:7px 8px;font-size:14px;cursor:pointer;font-family:inherit}
.controls button:hover:not(:disabled),.controls select:hover{background:#524437}
.controls button:disabled{opacity:.4;cursor:not-allowed}
.controls label{font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:3px;font-family:Arial,sans-serif}
.controls .full{grid-column:1/-1}
.foot{font-size:12px;color:var(--muted);font-family:Arial,sans-serif;line-height:1.4}
</style>
</head>
<body>
<div class="app">
  <div class="boardArea" id="boardArea">
    <div class="board" id="board"></div>
    <div class="banner" id="banner"></div>
    <div class="promo" id="promo" style="display:none"></div>
  </div>
  <div class="panel">
    <h1 class="title">Chess vs Computer</h1>
    <div class="status" id="status"><span class="dot-ind w" id="turnDot"></span><span id="statusText">White to move</span></div>
    <div class="trays">
      <div class="tray" id="trayBlack"></div>
      <div class="tray" id="trayWhite"></div>
    </div>
    <div class="moves" id="moves"><table id="movesTable"></table></div>
    <div class="controls">
      <button id="btnNew">New game</button>
      <button id="btnUndo">Undo</button>
      <button id="btnFlip">Flip board</button>
      <button id="btnMute">Sound: on</button>
      <label>Play as
        <select id="selMode">
          <option value="white">White (vs AI)</option>
          <option value="black">Black (vs AI)</option>
          <option value="both">Two players / analysis</option>
        </select>
      </label>
      <label>AI depth
        <select id="selDepth">
          <option value="1">1 - Easy</option>
          <option value="2">2 - Medium</option>
          <option value="3">3 - Hard</option>
        </select>
      </label>
    </div>
    <div class="foot">Click or drag pieces. Legal destinations show as dots; captures are ringed. Game state autosaves to this browser and restores on reload.</div>
  </div>
</div>
<script>
'use strict';
/* ==========================================================================
   CONFIG - tunable constants
   ========================================================================== */
const CONFIG = {
  colors: { light: '#ecdab4', dark: '#9d6b43', frame: '#3b2616', lastMove: 'rgba(255,226,90,.50)', selected: 'rgba(90,170,255,.60)', check: '#ff3b30' },
  ai: {
    depthByLevel: { 1: 1, 2: 2, 3: 3 },   // difficulty -> fixed max search depth (plies)
    timeLimitMs: 1500,                    // soft budget: stop deepening if exceeded
    aiDelayMs: 220,                       // delay before AI starts (lets UI paint)
    qsMaxPly: 6,                          // quiescence safety limit
    randomness: 0,                        // 0 = deterministic-ish (random tie-break only)
  },
  eval: {
    material: { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 },
    pstWeight: 1.0,                       // scale for piece-square tables
    mateScore: 100000,
  },
  storageKey: 'chess-vs-computer-v1',
  rules: { fiftyMoveHalfmoves: 100, threefoldCount: 3 },
};

/* ==========================================================================
   CORE CONSTANTS
   Board: mailbox of 64 squares, sq = rank*8 + file, rank 0 = rank "1", file 0 = "a".
   Piece chars: uppercase = White, lowercase = Black, '' = empty.
   ========================================================================== */
const FILES = 'abcdefgh';
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const KNIGHT_OFF = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]];
const KING_OFF   = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
const DIAG_DIRS  = [[1,1],[1,-1],[-1,1],[-1,-1]];
const ORTH_DIRS  = [[1,0],[-1,0],[0,1],[0,-1]];

const sqOf = (r, f) => r * 8 + f;
const rankOf = sq => sq >> 3;
const fileOf = sq => sq & 7;
const sqName = sq => FILES[fileOf(sq)] + (rankOf(sq) + 1);
const nameToSq = s => (FILES.indexOf(s[0])) + (parseInt(s[1], 10) - 1) * 8;
const colorOf = p => (p === '' ? null : (p === p.toUpperCase() ? 'w' : 'b'));
const typeOf = p => p.toUpperCase();
const inBoard = (r, f) => r >= 0 && r < 8 && f >= 0 && f < 8;
const oppColor = c => (c === 'w' ? 'b' : 'w');

/* ==========================================================================
   PIECE-SQUARE TABLES (White's perspective, index 0 = a8 ... 63 = h1)
   Black uses the mirrored index.
   ========================================================================== */
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],
  K: [-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],
};
const GLYPH = { w: { K:'\u2654', Q:'\u2655', R:'\u2656', B:'\u2657', N:'\u2658', P:'\u2659' },
                b: { K:'\u265A', Q:'\u265B', R:'\u265C', B:'\u265D', N:'\u265E', P:'\u265F' } };
const glyph = p => GLYPH[colorOf(p)][typeOf(p)];

/* ==========================================================================
   STATE
   state = { board: string[64], turn: 'w'|'b', castle: 'KQkq' subset, ep: sq|-1, half: int, full: int }
   ========================================================================== */
function parseFen(fen) {
  const parts = fen.split(' ');
  const board = new Array(64).fill('');
  const rows = parts[0].split('/');
  for (let i = 0; i < 8; i++) {
    let f = 0;
    for (const ch of rows[i]) {
      if (/\d/.test(ch)) f += parseInt(ch, 10);
      else { board[sqOf(7 - i, f)] = ch; f++; }
    }
  }
  return {
    board,
    turn: parts[1] || 'w',
    castle: parts[2] && parts[2] !== '-' ? parts[2] : '',
    ep: parts[3] && parts[3] !== '-' ? nameToSq(parts[3]) : -1,
    half: parseInt(parts[4] || '0', 10),
    full: parseInt(parts[5] || '1', 10),
  };
}
function toFen(s) {
  const rows = [];
  for (let r = 7; r >= 0; r--) {
    let row = '', empty = 0;
    for (let f = 0; f < 8; f++) {
      const p = s.board[sqOf(r, f)];
      if (!p) empty++;
      else { if (empty) { row += empty; empty = 0; } row += p; }
    }
    if (empty) row += empty;
    rows.push(row);
  }
  return [rows.join('/'), s.turn, s.castle || '-', s.ep >= 0 ? sqName(s.ep) : '-', s.half, s.full].join(' ');
}
const startState = () => parseFen(START_FEN);
const cloneState = s => ({ board: s.board.slice(), turn: s.turn, castle: s.castle, ep: s.ep, half: s.half, full: s.full });

/* ==========================================================================
   ATTACK DETECTION
   ========================================================================== */
function findKing(board, c) {
  const k = c === 'w' ? 'K' : 'k';
  for (let i = 0; i < 64; i++) if (board[i] === k) return i;
  return -1;
}
// Is square sq attacked by any piece of color byColor?
function isAttacked(board, sq, byColor) {
  const r = rankOf(sq), f = fileOf(sq);
  const P = byColor === 'w' ? 'P' : 'p', N = byColor === 'w' ? 'N' : 'n', K = byColor === 'w' ? 'K' : 'k';
  const B = byColor === 'w' ? 'B' : 'b', R = byColor === 'w' ? 'R' : 'r', Q = byColor === 'w' ? 'Q' : 'q';
  // pawns: a white pawn attacks upward, so it sits one rank below the target
  const pr = byColor === 'w' ? r - 1 : r + 1;
  if (inBoard(pr, f - 1) && board[sqOf(pr, f - 1)] === P) return true;
  if (inBoard(pr, f + 1) && board[sqOf(pr, f + 1)] === P) return true;
  for (const [dr, df] of KNIGHT_OFF) { const rr = r + dr, ff = f + df; if (inBoard(rr, ff) && board[sqOf(rr, ff)] === N) return true; }
  for (const [dr, df] of KING_OFF) { const rr = r + dr, ff = f + df; if (inBoard(rr, ff) && board[sqOf(rr, ff)] === K) return true; }
  for (const [dr, df] of ORTH_DIRS) {
    let rr = r + dr, ff = f + df;
    while (inBoard(rr, ff)) {
      const p = board[sqOf(rr, ff)];
      if (p) { if (p === R || p === Q) return true; break; }
      rr += dr; ff += df;
    }
  }
  for (const [dr, df] of DIAG_DIRS) {
    let rr = r + dr, ff = f + df;
    while (inBoard(rr, ff)) {
      const p = board[sqOf(rr, ff)];
      if (p) { if (p === B || p === Q) return true; break; }
      rr += dr; ff += df;
    }
  }
  return false;
}
function inCheck(s, c = s.turn) {
  const k = findKing(s.board, c);
  return k >= 0 && isAttacked(s.board, k, oppColor(c));
}

/* ==========================================================================
   MOVE APPLICATION
   Move object: { f, t, p (piece char), c (captured char or ''), pr (promo char or ''),
                  ep (bool), cs (0 none | 1 kingside | 2 queenside), dbl (bool) }
   ========================================================================== */
function applyMove(s, m) {
  const ns = cloneState(s);
  const b = ns.board;
  const piece = b[m.f];
  const color = s.turn;
  const type = typeOf(piece);
  const captured = m.c || '';
  b[m.t] = piece;
  b[m.f] = '';
  if (m.ep) b[m.t - (color === 'w' ? 8 : -8)] = '';        // remove the passed pawn
  if (m.pr) b[m.t] = color === 'w' ? m.pr.toUpperCase() : m.pr.toLowerCase();
  if (m.cs === 1) { b[m.t - 1] = b[m.t + 1]; b[m.t + 1] = ''; }   // rook h->f
  if (m.cs === 2) { b[m.t + 1] = b[m.t - 2]; b[m.t - 2] = ''; }   // rook a->d
  // castling rights
  let cr = ns.castle;
  if (type === 'K') cr = color === 'w' ? cr.replace(/[KQ]/g, '') : cr.replace(/[kq]/g, '');
  const rookSquares = { 0: 'Q', 7: 'K', 56: 'q', 63: 'k' };
  for (const sq of [m.f, m.t]) if (rookSquares[sq]) cr = cr.replace(rookSquares[sq], '');
  ns.castle = cr;
  ns.ep = m.dbl ? (m.f + m.t) >> 1 : -1;
  ns.half = (type === 'P' || captured || m.ep) ? 0 : s.half + 1;
  ns.full = color === 'b' ? s.full + 1 : s.full;
  ns.turn = oppColor(color);
  return ns;
}

/* ==========================================================================
   MOVE GENERATION
   pseudo-legal generation, then filtered by legality (king not left in check)
   ========================================================================== */
function pseudoMoves(s) {
  const moves = [];
  const c = s.turn, b = s.board, opp = oppColor(c);
  for (let sq = 0; sq < 64; sq++) {
    const p = b[sq];
    if (!p || colorOf(p) !== c) continue;
    const r = rankOf(sq), f = fileOf(sq), t = typeOf(p);
    const add = (to, extra) => {
      const cap = b[to];
      moves.push(Object.assign({ f: sq, t: to, p, c: cap || '', pr: '', ep: false, cs: 0, dbl: false }, extra || {}));
    };
    if (t === 'P') {
      const dir = c === 'w' ? 1 : -1, startR = c === 'w' ? 1 : 6, promoR = c === 'w' ? 7 : 0;
      const r1 = r + dir;
      if (inBoard(r1, f) && !b[sqOf(r1, f)]) {
        if (r1 === promoR) for (const pr of ['q', 'r', 'b', 'n']) add(sqOf(r1, f), { pr });
        else {
          add(sqOf(r1, f));
          const r2 = r + 2 * dir;
          if (r === startR && !b[sqOf(r2, f)]) add(sqOf(r2, f), { dbl: true });
        }
      }
      for (const df of [-1, 1]) {
        const ff = f + df, rr = r + dir;
        if (!inBoard(rr, ff)) continue;
        const to = sqOf(rr, ff);
        if (b[to] && colorOf(b[to]) === opp) {
          if (rr === promoR) for (const pr of ['q', 'r', 'b', 'n']) add(to, { pr });
          else add(to);
        } else if (to === s.ep && !b[to]) {
          add(to, { ep: true, c: b[sqOf(r, ff)] });
        }
      }
    } else if (t === 'N' || t === 'K') {
      const offs = t === 'N' ? KNIGHT_OFF : KING_OFF;
      for (const [dr, df] of offs) {
        const rr = r + dr, ff = f + df;
        if (!inBoard(rr, ff)) continue;
        const to = sqOf(rr, ff);
        if (!b[to] || colorOf(b[to]) === opp) add(to);
      }
      if (t === 'K') addCastling(s, sq, moves);
    } else {
      const dirs = t === 'B' ? DIAG_DIRS : t === 'R' ? ORTH_DIRS : DIAG_DIRS.concat(ORTH_DIRS);
      for (const [dr, df] of dirs) {
        let rr = r + dr, ff = f + df;
        while (inBoard(rr, ff)) {
          const to = sqOf(rr, ff);
          if (!b[to]) add(to);
          else { if (colorOf(b[to]) === opp) add(to); break; }
          rr += dr; ff += df;
        }
      }
    }
  }
  return moves;
}
// Castling: rights present, king & rook on home squares, path empty, king not in/through/into check.
function addCastling(s, sq, moves) {
  const c = s.turn, b = s.board, opp = oppColor(c);
  const home = c === 'w' ? 4 : 60;
  if (sq !== home) return;
  if (inCheck(s, c)) return;
  const kingSide = c === 'w' ? 'K' : 'k', queenSide = c === 'w' ? 'Q' : 'q';
  const K = c === 'w' ? 'K' : 'k', R = c === 'w' ? 'R' : 'r';
  if (s.castle.includes(kingSide) && b[home + 3] === R && !b[home + 1] && !b[home + 2] &&
      !isAttacked(b, home + 1, opp) && !isAttacked(b, home + 2, opp)) {
    moves.push({ f: home, t: home + 2, p: K, c: '', pr: '', ep: false, cs: 1, dbl: false });
  }
  if (s.castle.includes(queenSide) && b[home - 4] === R && !b[home - 1] && !b[home - 2] && !b[home - 3] &&
      !isAttacked(b, home - 1, opp) && !isAttacked(b, home - 2, opp)) {
    moves.push({ f: home, t: home - 2, p: K, c: '', pr: '', ep: false, cs: 2, dbl: false });
  }
}
function legalMoves(s) {
  const out = [];
  for (const m of pseudoMoves(s)) {
    if (m.cs && !castlePathOk(s, m)) continue;
    const ns = applyMove(s, m);
    if (!inCheck(ns, s.turn)) out.push(m);
  }
  return out;
}
function castlePathOk(s, m) { return true; } // path/attack checks already done in addCastling
// Legal captures only (for quiescence search)
function legalCaptures(s) {
  return legalMoves(s).filter(m => m.c || m.ep || m.pr === 'q');
}
const uciOf = m => sqName(m.f) + sqName(m.t) + (m.pr || '');

/* ==========================================================================
   STATUS / DRAW DETECTION
   ========================================================================== */
function insufficientMaterial(s) {
  const minors = { w: [], b: [] };
  let anyPawnOrMajor = false;
  for (let sq = 0; sq < 64; sq++) {
    const p = s.board[sq];
    if (!p) continue;
    const t = typeOf(p);
    if (t === 'K') continue;
    if (t === 'P' || t === 'R' || t === 'Q') { anyPawnOrMajor = true; break; }
    minors[colorOf(p)].push({ t, sq });
  }
  if (anyPawnOrMajor) return false;
  const all = minors.w.concat(minors.b);
  if (all.length === 0) return true;                    // K vs K
  if (all.length === 1) return true;                    // K+B vs K, K+N vs K
  // K+B vs K+B with bishops all on the same color is a dead draw
  if (all.every(x => x.t === 'B')) {
    const colors = all.map(x => (rankOf(x.sq) + fileOf(x.sq)) % 2);
    return colors.every(c => c === colors[0]);
  }
  return false;
}
function positionKey(s) {
  return s.board.map(x => x || '.').join('') + s.turn + (s.castle || '-') + (s.ep >= 0 ? s.ep : '-');
}

/* ==========================================================================
   NOTATION (SAN)
   ========================================================================== */
function toSAN(s, m, legal) {
  let str;
  if (m.cs === 1) str = 'O-O';
  else if (m.cs === 2) str = 'O-O-O';
  else {
    const t = typeOf(m.p);
    const capture = !!(m.c || m.ep);
    const dest = sqName(m.t);
    if (t === 'P') {
      str = (capture ? FILES[fileOf(m.f)] + 'x' : '') + dest;
      if (m.pr) str += '=' + m.pr.toUpperCase();
    } else {
      const others = legal.filter(o => o.p === m.p && o.t === m.t && o.f !== m.f);
      let dis = '';
      if (others.length) {
        const sameFile = others.some(o => fileOf(o.f) === fileOf(m.f));
        const sameRank = others.some(o => rankOf(o.f) === rankOf(m.f));
        if (!sameFile) dis = FILES[fileOf(m.f)];
        else if (!sameRank) dis = String(rankOf(m.f) + 1);
        else dis = sqName(m.f);
      }
      str = t + dis + (capture ? 'x' : '') + dest;
    }
  }
  const ns = applyMove(s, m);
  if (m.ep) str += ' e.p.';
  if (inCheck(ns, ns.turn)) str += legalMoves(ns).length ? '+' : '#';
  return str;
}

/* ==========================================================================
   EVALUATION
   ========================================================================== */
const MATERIAL = CONFIG.eval.material;
// Evaluation from White's point of view (positive = White better)
function evalWhite(s) {
  let v = 0;
  const b = s.board;
  for (let sq = 0; sq < 64; sq++) {
    const p = b[sq];
    if (!p) continue;
    const c = colorOf(p), t = typeOf(p);
    const tbl = PST[t];
    const pstIdx = c === 'w' ? (sq ^ 56) : sq;
    const val = MATERIAL[t.toLowerCase()] + CONFIG.eval.pstWeight * tbl[pstIdx];
    v += c === 'w' ? val : -val;
  }
  return v;
}
const evalFor = s => (s.turn === 'w' ? evalWhite(s) : -evalWhite(s));

/* ==========================================================================
   SEARCH - negamax + alpha-beta, captures-first ordering, quiescence-lite,
   iterative deepening with a soft time budget (search yields to UI between moves)
   ========================================================================== */
const INF = 1e9;
const SEARCH = { nodes: 0, deadline: 0, abort: false };
function orderMoves(moves) {
  const score = m => {
    let sc = 0;
    if (m.c || m.ep) sc += 10000 + MATERIAL[typeOf(m.c || 'P').toLowerCase()] * 10 - MATERIAL[typeOf(m.p).toLowerCase()] / 10;
    if (m.pr) sc += 8000;
    return sc;
  };
  return moves.map(m => [score(m), m]).sort((a, b) => b[0] - a[0]).map(x => x[1]);
}
function quiesce(s, alpha, beta, ply, qd) {
  if ((++SEARCH.nodes & 1023) === 0 && performance.now() > SEARCH.deadline) SEARCH.abort = true;
  if (SEARCH.abort) return 0;
  const stand = evalFor(s);
  if (stand >= beta) return stand;
  if (stand > alpha) alpha = stand;
  if (qd >= CONFIG.ai.qsMaxPly) return stand;
  const caps = orderMoves(pseudoMoves(s).filter(m => m.c || m.ep || m.pr));
  for (const m of caps) {
    const ns = applyMove(s, m);
    if (inCheck(ns, s.turn)) continue;
    const sc = -quiesce(ns, -beta, -alpha, ply + 1, qd + 1);
    if (SEARCH.abort) return 0;
    if (sc >= beta) return sc;
    if (sc > alpha) alpha = sc;
  }
  return alpha;
}
function negamax(s, depth, alpha, beta, ply) {
  if ((++SEARCH.nodes & 1023) === 0 && performance.now() > SEARCH.deadline) SEARCH.abort = true;
  if (SEARCH.abort) return 0;
  const moves = legalMoves(s);
  if (!moves.length) return inCheck(s, s.turn) ? -(CONFIG.eval.mateScore - ply) : 0;
  if (s.half >= CONFIG.rules.fiftyMoveHalfmoves || insufficientMaterial(s)) return 0;
  if (depth <= 0) return quiesce(s, alpha, beta, ply, 0);
  let best = -INF;
  for (const m of orderMoves(moves)) {
    const ns = applyMove(s, m);
    const sc = -negamax(ns, depth - 1, -beta, -alpha, ply + 1);
    if (SEARCH.abort) return 0;
    if (sc > best) best = sc;
    if (sc > alpha) alpha = sc;
    if (alpha >= beta) break;
  }
  return best;
}
// Returns the chosen legal move (from legalMoves(s)) or null.
function chooseMove(s, maxDepth) {
  let moves = legalMoves(s);
  if (!moves.length) return null;
  if (moves.length === 1) return moves[0];
  moves = orderMoves(moves);
  let best = moves[0];
  SEARCH.nodes = 0; SEARCH.abort = false;
  SEARCH.deadline = performance.now() + CONFIG.ai.timeLimitMs;
  for (let d = 1; d <= maxDepth; d++) {
    let alpha = -INF, bestD = null, bestScore = -INF;
    for (const m of moves) {
      const ns = applyMove(s, m);
      const sc = -negamax(ns, d - 1, -INF, -alpha, 1);
      if (SEARCH.abort) break;
      if (sc > bestScore || bestD === null) { bestScore = sc; bestD = m; }
      if (sc > alpha) alpha = sc;
    }
    if (SEARCH.abort) break;          // keep the last fully completed iteration
    best = bestD;
    // move the best to the front for the next iteration
    moves = [bestD].concat(moves.filter(m => m !== bestD));
    if (bestScore > CONFIG.eval.mateScore - 100) break;
  }
  return best;
}

/* ==========================================================================
   GAME OUTCOME
   ========================================================================== */
function computeOutcome(s, hist) {
  const legal = legalMoves(s);
  const chk = inCheck(s, s.turn);
  if (!legal.length) {
    if (chk) {
      const winner = s.turn === 'w' ? 'Black' : 'White';
      return { over: true, result: s.turn === 'w' ? '0-1' : '1-0', title: 'Checkmate', msg: `${winner} wins by checkmate.` };
    }
    return { over: true, result: '1/2-1/2', title: 'Stalemate', msg: 'No legal moves and not in check. Draw.' };
  }
  if (insufficientMaterial(s)) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Insufficient material to checkmate.' };
  if (s.half >= CONFIG.rules.fiftyMoveHalfmoves) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Fifty-move rule.' };
  const key = fen => fen.split(' ').slice(0, 4).join(' ');
  const cur = key(toFen(s));
  let reps = 1;
  for (const e of hist) if (key(e.fen) === cur) reps++;
  if (reps >= CONFIG.rules.threefoldCount) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Threefold repetition.' };
  return null;
}

/* ==========================================================================
   AUDIO (Web Audio, synthesized - no assets)
   ========================================================================== */
let AC = null;
let muted = (localStorage.getItem('chess-muted') === '1');
function tone(freq, dur, type, vol, delay, slideTo) {
  const t0 = AC.currentTime + (delay || 0);
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = type; o.frequency.setValueAtTime(freq, t0);
  if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
  g.gain.setValueAtTime(0.0001, t0);
  g.gain.exponentialRampToValueAtTime(vol, t0 + 0.005);
  g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
  o.connect(g); g.connect(AC.destination);
  o.start(t0); o.stop(t0 + dur + 0.02);
}
function playSound(kind) {
  if (muted) return;
  try {
    if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
    if (AC.state === 'suspended') AC.resume();
    if (kind === 'capture') { tone(220, 0.14, 'sawtooth', 0.12, 0, 90); tone(150, 0.08, 'square', 0.06); }
    else if (kind === 'check') { tone(880, 0.1, 'sine', 0.14); tone(1175, 0.16, 'sine', 0.12, 0.09); }
    else tone(600, 0.06, 'triangle', 0.16, 0, 420);
  } catch (e) { /* audio unavailable - ignore */ }
}

/* ==========================================================================
   CONTROLLER STATE
   G.state: current position. G.hist: [{fen (before move), san, f, t}].
   G.legal: legal moves of G.state. G.outcome: null or result object.
   ========================================================================== */
const G = {
  state: null, hist: [], legal: [], outcome: null,
  mode: 'white', depth: 2, flipped: false,
  sel: -1, targets: [], drag: null, ghost: null,
  aiToken: 0, aiBusy: false, bannerHidden: false, promo: null,
};
const $ = id => document.getElementById(id);
const boardEl = $('board'), boardArea = $('boardArea');

function humanColor() { return G.mode === 'black' ? 'b' : 'w'; }
function aiColor() { return G.mode === 'white' ? 'b' : (G.mode === 'black' ? 'w' : null); }
function humanCanMove() {
  if (G.outcome || G.aiBusy || G.promo) return false;
  return G.mode === 'both' || G.state.turn === humanColor();
}

/* Persistence -------------------------------------------------------- */
const SAVE_KEY = CONFIG.storageKey;
function saveGame() {
  try {
    localStorage.setItem(SAVE_KEY, JSON.stringify({
      v: 1, mode: G.mode, depth: G.depth, flipped: G.flipped,
      cur: toFen(G.state), hist: G.hist,
    }));
  } catch (e) { /* storage unavailable */ }
}
function loadGame() {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return false;
    const d = JSON.parse(raw);
    if (!d || d.v !== 1 || typeof d.cur !== 'string') return false;
    G.state = parseFen(d.cur);
    G.hist = Array.isArray(d.hist) ? d.hist : [];
    G.mode = ['white', 'black', 'both'].includes(d.mode) ? d.mode : 'white';
    G.depth = [1, 2, 3].includes(d.depth) ? d.depth : 2;
    G.flipped = !!d.flipped;
    return true;
  } catch (e) { return false; }
}

/* Core flow ----------------------------------------------------------- */
function refresh() {
  G.aiToken++;                       // any pending AI search is now stale
  G.legal = legalMoves(G.state);
  G.outcome = computeOutcome(G.state, G.hist);
  G.sel = -1; G.targets = []; G.bannerHidden = false;
  render();
  saveGame();
  scheduleAI();
}
function newGame() {
  G.state = startState(); G.hist = [];
  G.flipped = (G.mode === 'black');
  G.promo = null; hidePromo();
  refresh();
}
function commitMove(m) {
  const san = toSAN(G.state, m, G.legal);
  const captured = !!(m.c || m.ep);
  G.hist.push({ fen: toFen(G.state), san, f: m.f, t: m.t });
  G.state = applyMove(G.state, m);
  G.promo = null; hidePromo();
  const chk = inCheck(G.state, G.state.turn);
  playSound(chk ? 'check' : captured ? 'capture' : 'move');
  refresh();
}
function tryHumanMove(from, to) {
  if (!humanCanMove()) return false;
  const cands = G.legal.filter(m => m.f === from && m.t === to);
  if (!cands.length) return false;
  if (cands.length > 1 && cands[0].pr) { showPromo(cands); return true; }
  commitMove(cands[0]);
  return true;
}
function scheduleAI() {
  const ac = aiColor();
  if (G.outcome || !ac || G.state.turn !== ac) return render2();
  const token = G.aiToken;
  G.aiBusy = true;
  render2();
  setTimeout(() => {
    if (token !== G.aiToken) return;          // superseded by undo/new game/etc.
    const m = chooseMove(G.state, G.depth);
    G.aiBusy = false;
    if (!m) return render2();
    const legal = G.legal.find(x => x.f === m.f && x.t === m.t && x.pr === m.pr);
    if (!legal) return render2();              // should never happen
    commitMove(legal);
  }, CONFIG.ai.aiDelayMs);
}
function undo() {
  G.aiToken++; G.aiBusy = false;
  if (!G.hist.length) return;
  const ac = aiColor();
  let e = G.hist.pop();
  // undo whole turn cycle: step back until it is the human's turn again
  while (ac && G.state && G.hist.length && (G.state.turn === ac || parseFen(e.fen).turn === ac)) {
    G.state = parseFen(e.fen);
    if (parseFen(e.fen).turn !== ac) break;
    e = G.hist.pop();
  }
  G.state = parseFen(e.fen);
  G.promo = null; hidePromo();
  refresh();
}

/* ==========================================================================
   RENDERING
   ========================================================================== */
function sqToDisplay(sq) {
  const r = rankOf(sq), f = fileOf(sq);
  return { dr: G.flipped ? r : 7 - r, dc: G.flipped ? 7 - f : f };
}
function sqAtPoint(x, y) {
  const rect = boardEl.getBoundingClientRect();
  if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) return -1;
  const cell = rect.width / 8;
  const dr = Math.floor((y - rect.top) / cell), dc = Math.floor((x - rect.left) / cell);
  const r = G.flipped ? dr : 7 - dr, f = G.flipped ? 7 - dc : dc;
  return sqOf(r, f);
}
function kingInCheckSq() {
  if (!inCheck(G.state, G.state.turn)) return -1;
  return findKing(G.state.board, G.state.turn);
}
function renderBoard() {
  boardEl.innerHTML = '';
  const last = G.hist.length ? G.hist[G.hist.length - 1] : null;
  const chkSq = kingInCheckSq();
  const dragFrom = G.drag && G.drag.started ? G.drag.from : -1;
  for (let dr = 0; dr < 8; dr++) for (let dc = 0; dc < 8; dc++) {
    const r = G.flipped ? dr : 7 - dr, f = G.flipped ? 7 - dc : dc;
    const sq = sqOf(r, f);
    const div = document.createElement('div');
    const light = (r + f) % 2 === 1;
    div.className = 'sq ' + (light ? 'light' : 'dark');
    div.dataset.sq = sq;
    if (last && (sq === last.f || sq === last.t)) div.classList.add('last');
    if (G.sel === sq) div.classList.add('sel');
    if (sq === chkSq) div.classList.add('check');
    const tgt = G.targets.find(m => m.t === sq);
    if (tgt) div.classList.add((G.state.board[sq] || tgt.ep) ? 'ring' : 'dot');
    if (dc === 0) { const c = document.createElement('span'); c.className = 'coord r'; c.textContent = r + 1; div.appendChild(c); }
    if (dr === 7) { const c = document.createElement('span'); c.className = 'coord f'; c.textContent = FILES[f]; div.appendChild(c); }
    const p = G.state.board[sq];
    if (p) {
      const s = document.createElement('span');
      s.className = 'pc ' + colorOf(p) + (sq === dragFrom ? ' dragging' : '');
      s.textContent = glyph(p);
      div.appendChild(s);
    }
    boardEl.appendChild(div);
  }
}
function setTargets() {
  G.targets = G.sel >= 0 ? G.legal.filter(m => m.f === G.sel) : [];
}
function sideName(c) { return c === 'w' ? 'White' : 'Black'; }
function renderStatus() {
  const s = G.state;
  const dot = $('turnDot');
  dot.className = 'dot-ind ' + (G.outcome && G.outcome.over ? '' : s.turn);
  dot.style.background = G.outcome ? '#888' : '';
  let text;
  if (G.outcome) text = `${G.outcome.title} - ${G.outcome.result === '1/2-1/2' ? 'draw' : (G.outcome.result === '1-0' ? 'White wins' : 'Black wins')}`;
  else if (G.aiBusy) text = `${sideName(s.turn)} (computer) is thinking\u2026`;
  else {
    text = `${sideName(s.turn)} to move`;
    if (G.mode !== 'both') text += s.turn === humanColor() ? ' (you)' : ' (computer)';
    if (inCheck(s, s.turn)) text += ' - check!';
  }
  $('statusText').innerHTML = '';
  const st = document.createElement('span');
  st.textContent = text;
  if (G.aiBusy) st.className = 'thinking';
  $('statusText').appendChild(st);
  $('statusText').className = '';
}
function renderTrays() {
  const counts = { w: { P: 0, N: 0, B: 0, R: 0, Q: 0 }, b: { P: 0, N: 0, B: 0, R: 0, Q: 0 } };
  for (const p of G.state.board) if (p) counts[colorOf(p)][typeOf(p)]++;
  const init = { P: 8, N: 2, B: 2, R: 2, Q: 1 };
  const val = { P: 1, N: 3, B: 3, R: 5, Q: 9 };
  const fill = (color) => {        // pieces of `color` that were captured
    const out = []; let sum = 0;
    for (const t of ['Q', 'R', 'B', 'N', 'P']) {
      const n = init[t] - counts[color][t];
      for (let i = 0; i < n; i++) { out.push(GLYPH[color][t]); }
      sum += n * val[t];
    }
    return { out, sum };
  };
  const cw = fill('b'), cb = fill('w');   // cw: black pieces taken by White
  const adv = cw.sum - cb.sum;
  $('trayBlack').innerHTML = `<span class="lbl">Taken by White</span><span class="gl">${cw.out.join('')}</span>${adv > 0 ? `<span class="adv">+${adv}</span>` : ''}`;
  $('trayWhite').innerHTML = `<span class="lbl">Taken by Black</span><span class="gl">${cb.out.join('')}</span>${adv < 0 ? `<span class="adv">+${-adv}</span>` : ''}`;
}
function renderMoves() {
  const tbl = $('movesTable');
  let html = '';
  for (let i = 0; i < G.hist.length; i += 2) {
    const w = G.hist[i], b = G.hist[i + 1];
    const cur = (i === G.hist.length - 1) ? ' cur' : (b && i + 1 === G.hist.length - 1 ? ' cur' : '');
    html += `<tr class="${cur.trim()}"><td class="n">${i / 2 + 1}.</td>` +
      `<td class="m">${w ? escapeHtml(w.san) : ''}</td>` +
      `<td class="m">${b ? escapeHtml(b.san) : ''}</td></tr>`;
  }
  if (G.outcome && G.outcome.over) html += `<tr><td class="n"></td><td class="m" colspan="2" style="color:#e0a94a">${G.outcome.result}</td></tr>`;
  tbl.innerHTML = html;
  const box = $('moves');
  box.scrollTop = box.scrollHeight;
}
function escapeHtml(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function renderBanner() {
  const b = $('banner');
  if (G.outcome && G.outcome.over && !G.bannerHidden) {
    b.innerHTML = `<strong>${G.outcome.title}</strong><br>${G.outcome.result === '1/2-1/2' ? 'Result: draw (1/2-1/2)' : 'Result: ' + G.outcome.result}` +
      `<small>${G.outcome.msg}</small><small>Click to dismiss, or press New game.</small>`;
    b.classList.add('show');
  } else b.classList.remove('show');
}
function renderButtons() {
  $('btnUndo').disabled = G.hist.length === 0;
  $('btnMute').textContent = 'Sound: ' + (muted ? 'off' : 'on');
  $('selMode').value = G.mode;
  $('selDepth').value = String(G.depth);
}
function render2() { render(); }   // alias used by AI scheduling
function render() {
  setTargets();
  renderBoard();
  renderStatus();
  renderTrays();
  renderMoves();
  renderBanner();
  renderButtons();
}

/* Promotion picker --------------------------------------------------- */
function showPromo(cands) {
  const cell = boardEl.getBoundingClientRect().width / 8;
  const to = cands[0].t;
  const { dr, dc } = sqToDisplay(to);
  const pop = $('promo');
  pop.innerHTML = '';
  const color = G.state.turn;
  for (const m of cands) {
    const b = document.createElement('button');
    b.innerHTML = `<span class="pc ${color}">${GLYPH[color][m.pr.toUpperCase()]}</span>`;
    b.title = { q: 'Queen', r: 'Rook', b: 'Bishop', n: 'Knight' }[m.pr];
    b.onclick = (e) => { e.stopPropagation(); G.promo = null; hidePromo(); commitMove(m); };
    pop.appendChild(b);
  }
  const cancel = document.createElement('button');
  cancel.className = 'promo-cancel'; cancel.textContent = 'cancel';
  cancel.style.width = '100%'; cancel.style.height = 'auto'; cancel.style.background = 'none';
  cancel.onclick = (e) => { e.stopPropagation(); G.promo = null; hidePromo(); render(); };
  pop.appendChild(cancel);
  const left = dc * cell + 10 + 1, belowHalf = dr >= 4;
  let top = belowHalf ? dr * cell + 10 - (cands.length * cell) + cell : dr * cell + 10;
  top = Math.max(10, Math.min(top, boardArea.clientHeight - 10 - cands.length * cell - 28));
  pop.style.left = left + 'px';
  pop.style.top = top + 'px';
  pop.style.display = 'flex';
  G.promo = cands;
}
function hidePromo() { const p = $('promo'); if (p) p.style.display = 'none'; }

/* Pointer input: click-to-move and drag-and-drop --------------------- */
boardEl.addEventListener('pointerdown', e => {
  if (e.button !== 0 && e.pointerType === 'mouse') return;
  const sq = sqAtPoint(e.clientX, e.clientY);
  if (sq < 0 || G.promo) return;
  e.preventDefault();
  const p = G.state.board[sq];
  const own = p && colorOf(p) === G.state.turn;
  if (own && humanCanMove()) {
    G.sel = sq; setTargets();
    G.drag = { from: sq, x0: e.clientX, y0: e.clientY, started: false, ptr: e.pointerId };
    render();
  } else {
    G.drag = { from: -1, x0: e.clientX, y0: e.clientY, started: false, ptr: e.pointerId };
  }
});
window.addEventListener('pointermove', e => {
  const d = G.drag;
  if (!d || e.pointerId !== d.ptr) return;
  if (!d.started && d.from >= 0 && Math.hypot(e.clientX - d.x0, e.clientY - d.y0) > 5) {
    d.started = true;
    const p = G.state.board[d.from];
    const g = document.createElement('div');
    g.className = 'ghost ' + colorOf(p);
    g.textContent = glyph(p);
    document.body.appendChild(g);
    G.ghost = g;
    renderBoard();   // hide the source piece
  }
  if (G.ghost) { G.ghost.style.left = e.clientX + 'px'; G.ghost.style.top = e.clientY + 'px'; }
});
window.addEventListener('pointerup', e => {
  const d = G.drag;
  if (!d || e.pointerId !== d.ptr) return;
  G.drag = null;
  if (G.ghost) { G.ghost.remove(); G.ghost = null; }
  const sq = sqAtPoint(e.clientX, e.clientY);
  if (d.started) {
    if (sq >= 0 && sq !== d.from && G.legal.some(m => m.f === d.from && m.t === sq)) {
      G.sel = d.from;
      tryHumanMove(d.from, sq);
      return;
    }
    render();                       // dropped illegally: snap back, keep selection
    return;
  }
  // click handling
  if (sq < 0) return;
  if (G.sel >= 0 && sq !== G.sel && G.targets.some(m => m.t === sq)) {
    tryHumanMove(G.sel, sq);
    return;
  }
  if (G.sel >= 0 && sq === G.sel) { G.sel = -1; setTargets(); render(); return; }
  if (!(G.state.board[sq] && colorOf(G.state.board[sq]) === G.state.turn)) {
    G.sel = -1; setTargets(); render();
  }
});
// Cancel drag if pointer leaves window in a weird way
window.addEventListener('pointercancel', () => { if (G.ghost) G.ghost.remove(); G.ghost = null; G.drag = null; render(); });

$('banner').addEventListener('click', () => { G.bannerHidden = true; renderBanner(); });
$('btnNew').onclick = () => newGame();
$('btnUndo').onclick = () => undo();
$('btnFlip').onclick = () => { G.flipped = !G.flipped; render(); saveGame(); };
$('btnMute').onclick = () => { muted = !muted; localStorage.setItem('chess-muted', muted ? '1' : '0'); renderButtons(); };
$('selMode').onchange = e => {
  G.mode = e.target.value;
  if (G.mode === 'black') G.flipped = true; else if (G.mode === 'white') G.flipped = false;
  refresh();
};
$('selDepth').onchange = e => { G.depth = parseInt(e.target.value, 10); saveGame(); renderButtons(); };
document.addEventListener('keydown', e => { if (e.key === 'Escape' && G.promo) { G.promo = null; hidePromo(); render(); } });

/* ==========================================================================
   DEBUG INTERFACE (window.game) - for headless probing
   ========================================================================== */
function resolveMove(str) {
  const s = String(str).trim();
  const uciRe = /^[a-h][1-8][a-h][1-8][qrbn]?$/i;
  if (uciRe.test(s)) {
    const f = nameToSq(s.slice(0, 2).toLowerCase()), t = nameToSq(s.slice(2, 4).toLowerCase());
    const pr = s.length === 5 ? s[4].toLowerCase() : null;
    const c = G.legal.filter(m => m.f === f && m.t === t);
    if (!c.length) return null;
    return (pr ? c.find(m => m.pr === pr) : (c.find(m => m.pr === 'q') || c[0])) || null;
  }
  const norm = x => x.replace(/[x+#=!?]/g, '').replace(/e\.p\./, '').trim();
  const want = norm(s);
  return G.legal.find(m => norm(toSAN(G.state, m, G.legal)) === want) || null;
}
window.game = {
  fen: () => toFen(G.state),
  turn: () => G.state.turn,
  legalMoves: () => G.legal.map(m => uciOf(m)),
  legalSAN: () => G.legal.map(m => toSAN(G.state, m, G.legal)),
  move(str) {
    if (G.outcome) return { ok: false, reason: 'game over: ' + G.outcome.title };
    if (G.aiBusy) return { ok: false, reason: 'AI thinking' };
    const m = resolveMove(str);
    if (!m) return { ok: false, reason: 'illegal or unknown' };
    const san = toSAN(G.state, m, G.legal);
    commitMove(m);
    return { ok: true, san };
  },
  history: () => G.hist.map(h => h.san),
  outcome: () => G.outcome,
  undo: () => { undo(); return window.game.fen(); },
  newGame: (mode) => { if (mode) { G.mode = mode; } newGame(); return window.game.fen(); },
  setMode: (m) => { G.mode = m; $('selMode').value = m; refresh(); },
  setDepth: (d) => { G.depth = d; saveGame(); renderButtons(); },
  aiBusy: () => G.aiBusy,
  perft: (fen, d) => perft(parseFen(fen || toFen(G.state)), d),
};
function perft(s, d) {
  if (d === 0) return 1;
  let n = 0;
  for (const m of legalMoves(s)) n += perft(applyMove(s, m), d - 1);
  return n;
}

/* ==========================================================================
   INIT
   ========================================================================== */
(function init() {
  if (!loadGame()) { G.state = startState(); G.hist = []; }
  G.legal = legalMoves(G.state);
  G.outcome = computeOutcome(G.state, G.hist);
  $('selMode').value = G.mode;
  renderBanner();
  render();
  scheduleAI();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"bbf5c7b0-ee23-4bb4-9595-920068a8a563","tokensIn":389148,"tokensOut":240620,"tokensTotal":629768,"cost":0.12969976499999997,"turns":14,"toolCalls":16,"failedToolCalls":2,"timestamp":"2026-10-08T01:40:20.750Z"} -->