Chess
muse spark 1.3 contributor · full

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 47,192 bytes · SHA-256 db1046e8a01c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grandmaster — Chess vs Computer</title>
<style>
:root{
--light:#ecd9b0; --dark:#a5714b; --light2:#f0dcb4; --dark2:#96683f;
--bg:#14110d; --panel:#1e1a14; --panel2:#262019; --gold:#d4a94e; --gold2:#f2cd7e;
--ink:#efe6d3; --muted:#a89a80; --green:#7fa650; --red:#e05252;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;background:radial-gradient(1200px 800px at 50% -10%,#2b241a 0%,var(--bg) 60%);color:var(--ink);font-family:Georgia,'Times New Roman',serif;min-height:100vh;}
header{display:flex;align-items:center;justify-content:space-between;padding:14px 22px;max-width:1180px;margin:0 auto;}
.brand{display:flex;align-items:center;gap:12px;}
.brand .knight{font-size:34px;filter:drop-shadow(0 2px 6px rgba(0,0,0,.6));}
.brand h1{font-size:22px;margin:0;letter-spacing:3px;font-weight:700;color:var(--gold2);}
.brand h1 small{display:block;font-size:11px;letter-spacing:4px;color:var(--muted);font-family:system-ui,sans-serif;}
.top-controls{display:flex;gap:8px;align-items:center;font-family:system-ui,sans-serif;}
button,select{background:#2e271c;border:1px solid #4a3f2c;color:var(--ink);border-radius:10px;padding:8px 12px;font-size:13px;cursor:pointer;transition:.15s;}
button:hover,select:hover{border-color:var(--gold);transform:translateY(-1px);}
button.primary{background:linear-gradient(180deg,#e8bc62,#c99635);color:#231a08;font-weight:700;border-color:#8a6a24;}
button:disabled{opacity:.45;cursor:default;transform:none;}
main{max-width:1180px;margin:0 auto;padding:6px 22px 30px;display:grid;grid-template-columns:minmax(320px,640px) 340px;gap:22px;justify-content:center;}
@media(max-width:900px){main{grid-template-columns:1fr;} header{flex-wrap:wrap;gap:10px;}}
.board-wrap{position:relative;}
.turn-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-family:system-ui,sans-serif;font-size:13px;}
.turn-dot{width:12px;height:12px;border-radius:50%;background:#fff;box-shadow:0 0 10px #fff;}
.turn-dot.b{background:#111;box-shadow:0 0 0 2px #888;}
#statusText{color:var(--ink);} #statusText b{color:var(--gold2);}
.board-outer{background:linear-gradient(180deg,#3a2d1d,#211a10);padding:14px;border-radius:16px;border:1px solid #4a3c26;box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08);}
.coords{display:flex;justify-content:space-between;padding:0 4px 6px;color:var(--muted);font-size:11px;font-family:system-ui,sans-serif;}
#board{display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);aspect-ratio:1/1;border-radius:10px;overflow:hidden;border:3px solid #120e08;position:relative;touch-action:none;user-select:none;}
.sq{position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;}
.sq.light{background:linear-gradient(180deg,var(--light2),var(--light));}
.sq.dark{background:linear-gradient(180deg,var(--dark),var(--dark2));}
.sq.last::after{content:'';position:absolute;inset:0;background:rgba(212,169,78,.45);pointer-events:none;}
.sq.selected::after{content:'';position:absolute;inset:0;background:rgba(46,160,67,.45);box-shadow:inset 0 0 0 3px #2ea043;pointer-events:none;}
.sq.check::before{content:'';position:absolute;inset:6%;border-radius:18%;background:radial-gradient(circle,rgba(255,40,40,.85) 20%,rgba(255,40,40,0) 70%);filter:blur(1px);pointer-events:none;}
.sq.check{box-shadow:inset 0 0 0 4px #ff2d2d, inset 0 0 24px #ff2d2d;}
.piece{font-size:clamp(28px,6.2vw,52px);line-height:1;pointer-events:none;z-index:2;transition:transform .08s;}
.piece.w{color:#faf5e8;text-shadow:0 0 2px #000,0 2px 0 #000,-1px 0 0 #000,1px 0 0 #000,0 -1px 0 #000,0 4px 8px rgba(0,0,0,.6);}
.piece.b{color:#15110c;text-shadow:0 0 2px rgba(255,255,255,.35),0 1px 0 #fff3,0 4px 8px rgba(0,0,0,.5);}
.hint-dot{position:absolute;width:26%;height:26%;border-radius:50%;background:rgba(20,60,20,.38);box-shadow:0 0 0 3px rgba(20,60,20,.15);pointer-events:none;z-index:3;}
.hint-ring{position:absolute;inset:6%;border-radius:50%;border:5px solid rgba(200,30,30,.75);pointer-events:none;z-index:3;}
.sq.hover-target{outline:3px solid #2ea043;outline-offset:-3px;}
#dragPiece{position:fixed;pointer-events:none;z-index:99;font-size:52px;display:none;transform:translate(-50%,-58%);filter:drop-shadow(0 10px 12px rgba(0,0,0,.6));}
.panel{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid #4a3c26;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:12px;min-height:400px;box-shadow:0 20px 60px rgba(0,0,0,.4);}
.panel h3{margin:0;font-size:11px;letter-spacing:2px;color:var(--muted);font-family:system-ui,sans-serif;}
.trays{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.tray{background:#14110c;border:1px solid #3a3122;border-radius:10px;padding:8px;min-height:56px;}
.tray .cap{font-size:20px;line-height:1.2;letter-spacing:1px;}
.tray .cap.w{color:#faf5e8;text-shadow:0 1px 0 #000;}
.tray .cap.b{color:#8d8270;}
.tray .score{font-family:system-ui,sans-serif;font-size:11px;color:var(--gold2);}
#moves{background:#100d08;border:1px solid #3a3122;border-radius:10px;height:220px;overflow-y:auto;padding:8px 10px;font-family:system-ui,sans-serif;font-size:13px;}
#moves table{width:100%;border-collapse:collapse;}
#moves td{padding:3px 6px;border-radius:6px;}
#moves tr:nth-child(odd) td{background:rgba(255,255,255,.03);}
#moves td.num{color:var(--muted);width:34px;}
#moves td.cur{background:rgba(212,169,78,.25);}
.controls-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-family:system-ui,sans-serif;}
.controls-grid .row{display:flex;gap:8px;grid-column:1/-1;}
label.small{font-size:12px;color:var(--muted);font-family:system-ui,sans-serif;display:flex;align-items:center;gap:6px;}
select{width:100%;}
#banner{position:absolute;inset:14px;border-radius:12px;display:none;align-items:center;justify-content:center;flex-direction:column;gap:8px;background:rgba(10,8,5,.82);backdrop-filter:blur(4px);z-index:20;text-align:center;padding:20px;}
#banner.show{display:flex;}
#banner h2{margin:0;font-size:34px;color:var(--gold2);letter-spacing:1px;}
#banner p{margin:0;color:var(--ink);font-family:system-ui,sans-serif;}
#promo{position:absolute;z-index:30;display:none;background:#1e1a14;border:2px solid var(--gold);border-radius:12px;padding:8px;gap:6px;box-shadow:0 12px 30px rgba(0,0,0,.7);}
#promo.show{display:flex;}
#promo button{font-size:34px;padding:4px 10px;line-height:1;background:#2e271c;}
#promo button:hover{background:var(--gold);color:#000;}
.thinking{display:none;font-family:system-ui,sans-serif;font-size:12px;color:var(--gold2);align-items:center;gap:8px;}
.thinking.show{display:flex;}
.spinner{width:14px;height:14px;border:2px solid #55482f;border-top-color:var(--gold2);border-radius:50%;animation:spin 0.7s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.footer-note{max-width:1180px;margin:0 auto;padding:0 22px 30px;color:var(--muted);font-size:12px;font-family:system-ui,sans-serif;}
kbd{background:#0006;border:1px solid #555;border-radius:4px;padding:0 5px;}
</style>
</head>
<body>
<header>
<div class="brand"><div class="knight">♞</div><h1>GRANDMASTER<small>CHESS vs COMPUTER</small></h1></div>
<div class="top-controls">
<button id="btnNew" class="primary">+ New Game</button>
<button id="btnUndo">↩ Undo</button>
<button id="btnFlip">⇄ Flip</button>
<button id="btnSound">🔊</button>
</div>
</header>
<main>
<div class="board-wrap">
<div class="turn-bar"><div id="turnDot" class="turn-dot"></div><div id="statusText">White to move</div><div style="flex:1"></div><div id="think" class="thinking"><div class="spinner"></div><span id="thinkText">AI thinking…</span></div></div>
<div class="board-outer">
<div class="coords" id="topCoords"></div>
<div id="board"></div>
<div class="coords" id="botCoords"></div>
<div id="banner"><h2 id="bannerTitle">Checkmate</h2><p id="bannerSub">White wins</p><button id="btnAgain" class="primary">New Game</button></div>
<div id="promo"></div>
</div>
</div>
<div class="panel">
<div>
<h3>CAPTURED</h3>
<div class="trays" style="margin-top:6px">
<div class="tray"><div style="font-size:11px;color:var(--muted);font-family:system-ui">BY WHITE</div><div id="capByW" class="cap b"></div><div id="scoreW" class="score"></div></div>
<div class="tray"><div style="font-size:11px;color:var(--muted);font-family:system-ui">BY BLACK</div><div id="capByB" class="cap w"></div><div id="scoreB" class="score"></div></div>
</div>
</div>
<div>
<h3>MOVES</h3>
<div id="moves" style="margin-top:6px"><table id="movesTable"></table></div>
</div>
<div class="controls-grid">
<label class="small">Play as<br></label><label class="small">Difficulty<br></label>
<select id="selColor"><option value="w">White (you first)</option><option value="b">Black (AI first)</option><option value="both">Analysis — both sides</option></select>
<select id="selDepth"><option value="1">Easy — depth 1</option><option value="2" selected>Normal — depth 2</option><option value="3">Hard — depth 3</option></select>
<div class="row">
<button id="btnHint" style="flex:1">💡 Hint</button>
<button id="btnResign" style="flex:1">🏳 Resign</button>
</div>
</div>
<div style="font-size:12px;color:var(--muted);font-family:system-ui;line-height:1.5">Click or drag to move. Dots = moves, ring = capture.<br>FEN: <span id="fenPreview" style="word-break:break-all;color:#7a6f58"></span></div>
</div>
</main>
<div class="footer-note">Fully rule-complete engine: castling, en passant, promotion, pins, check / mate / stalemate, 50-move & insufficient-material draws. Progress auto-saves to this browser.</div>
<div id="dragPiece"></div>
<script>
'use strict';
/* ============================================================
1. TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
AI_DEPTH_DEFAULT: 2,
AI_DEPTHS: {1:1, 2:2, 3:3},
QUIESCE_DEPTH: 2,
VAL: { P:100, N:320, B:330, R:500, Q:900, K:0 },
COLORS: { light:'#ecd9b0', dark:'#a5714b' },
SAVE_KEY: 'grandmaster_chess_v1',
THINK_DELAY_MS: 60
};
const GLYPH = { P:'♟', N:'♞', B:'♝', R:'♜', Q:'♛', K:'♚' };
const FILES = 'abcdefgh';
/* ============================================================
2. SOUND (Web Audio, no assets)
============================================================ */
const Sound = {
enabled: true, ctx:null,
ensure(){ if(!this.ctx){ try{ this.ctx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
tone(freq,dur,type='sine',gain=0.15,when=0){ if(!this.enabled) return; this.ensure(); if(!this.ctx) return;
const t=this.ctx.currentTime+when, o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.value=freq; g.gain.setValueAtTime(gain,t); g.gain.exponentialRampToValueAtTime(0.001,t+dur);
o.connect(g); g.connect(this.ctx.destination); o.start(t); o.stop(t+dur+0.02); },
move(){ this.tone(520,0.09,'triangle',0.12); this.tone(780,0.07,'triangle',0.06,0.03); },
capture(){ this.tone(220,0.12,'sawtooth',0.12); this.tone(140,0.15,'square',0.08,0.02); },
check(){ this.tone(880,0.12,'sine',0.14); this.tone(880,0.12,'sine',0.14,0.14); },
castle(){ this.tone(400,0.08,'triangle',0.12); this.tone(600,0.1,'triangle',0.12,0.07); },
end(win){ [523,659,784,1046].forEach((f,i)=>this.tone(f,0.18,'triangle',0.12,i*0.11)); },
notify(){ this.tone(660,0.1,'sine',0.1); }
};
/* ============================================================
3. ENGINE — board + move generation (separated from UI)
Representation: board[64], idx = rankRow*8+file (row0=rank8 top).
Piece: {c:'w'|'b', t:'P'|'N'|'B'|'R'|'Q'|'K'}
============================================================ */
function idx(r,c){ return r*8+c; }
function rc(i){ return [Math.floor(i/8), i%8]; }
function sqName(i){ const [r,c]=rc(i); return FILES[c]+(8-r); }
function sqIndex(name){ const c=FILES.indexOf(name[0]), rank=+name[1]; return (8-rank)*8+c; }
function inB(r,c){ return r>=0&&r<8&&c>=0&&c<8; }
function opp(c){ return c==='w'?'b':'w'; }
function initialBoard(){
const order=['R','N','B','Q','K','B','N','R'];
const b=new Array(64).fill(null);
for(let c=0;c<8;c++){
b[idx(0,c)]={c:'b',t:order[c]}; b[idx(1,c)]={c:'b',t:'P'};
b[idx(6,c)]={c:'w',t:'P'}; b[idx(7,c)]={c:'w',t:order[c]};
}
return b;
}
function newState(){
return { board: initialBoard(), turn:'w', castling:{K:true,Q:true,k:true,q:true}, ep:null, half:0, full:1 };
}
function cloneState(s){
return { board: s.board.map(p=>p?{c:p.c,t:p.t}:null), turn:s.turn, castling:{...s.castling}, ep:s.ep, half:s.half, full:s.full };
}
// --- attack detection ---
function isAttacked(board, sq, by){
const [r,c]=rc(sq);
// pawns
const pr = by==='w' ? r+1 : r-1;
for(const dc of [-1,1]){ const cc=c+dc; if(inB(pr,cc)){ const p=board[idx(pr,cc)]; if(p&&p.c===by&&p.t==='P') return true; } }
// knights
for(const [dr,dc] of [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]]){
const rr=r+dr,cc=c+dc; if(inB(rr,cc)){ const p=board[idx(rr,cc)]; if(p&&p.c===by&&p.t==='N') return true; }
}
// king
for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++){ if(!dr&&!dc)continue; const rr=r+dr,cc=c+dc;
if(inB(rr,cc)){ const p=board[idx(rr,cc)]; if(p&&p.c===by&&p.t==='K') return true; } }
// sliders
const dirs=[[-1,-1],[-1,1],[1,-1],[1,1]];
for(const [dr,dc] of dirs){ let rr=r+dr,cc=c+dc;
while(inB(rr,cc)){ const p=board[idx(rr,cc)]; if(p){ if(p.c===by&&(p.t==='B'||p.t==='Q')) return true; break; } rr+=dr;cc+=dc; } }
const dirs2=[[-1,0],[1,0],[0,-1],[0,1]];
for(const [dr,dc] of dirs2){ let rr=r+dr,cc=c+dc;
while(inB(rr,cc)){ const p=board[idx(rr,cc)]; if(p){ if(p.c===by&&(p.t==='R'||p.t==='Q')) return true; break; } rr+=dr;cc+=dc; } }
return false;
}
function kingSq(board,color){ for(let i=0;i<64;i++){ const p=board[i]; if(p&&p.c===color&&p.t==='K') return i; } return -1; }
function inCheck(state,color){ const k=kingSq(state.board,color); if(k<0) return false; return isAttacked(state.board,k,opp(color)); }
// --- pseudo move generation ---
function pseudoMoves(state, from=null){
const moves=[]; const b=state.board;
const squares = from!=null ? [from] : [...Array(64).keys()].filter(i=>b[i]&&b[i].c===state.turn);
for(const f of squares){
const p=b[f]; if(!p||p.c!==state.turn) continue;
const [r,c]=rc(f);
if(p.t==='P'){
const dir = p.c==='w'?-1:1;
const startRow = p.c==='w'?6:1;
const lastRow = p.c==='w'?0:7;
const oneR=r+dir;
if(inB(oneR,c)&&!b[idx(oneR,c)]){
if(oneR===lastRow){ for(const pr of ['Q','R','B','N']) moves.push({from:f,to:idx(oneR,c),piece:{...p},promotion:pr,isCapture:false,flag:'promo'}); }
else { moves.push({from:f,to:idx(oneR,c),piece:{...p},isCapture:false,flag:'normal'});
if(r===startRow){ const twoR=r+2*dir; if(!b[idx(twoR,c)]) moves.push({from:f,to:idx(twoR,c),piece:{...p},isCapture:false,flag:'double'}); } }
}
for(const dc of [-1,1]){
const cc=c+dc, rr=r+dir; if(!inB(rr,cc)) continue;
const t=idx(rr,cc), tp=b[t];
if(tp&&tp.c!==p.c){
if(rr===lastRow){ for(const pr of ['Q','R','B','N']) moves.push({from:f,to:t,piece:{...p},captured:{...tp},promotion:pr,isCapture:true,flag:'promo'}); }
else moves.push({from:f,to:t,piece:{...p},captured:{...tp},isCapture:true,flag:'normal'});
} else if(!tp && state.ep===t){
moves.push({from:f,to:t,piece:{...p},isCapture:true,flag:'ep'});
}
}
} else if(p.t==='N'){
for(const [dr,dc] of [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]]){
const rr=r+dr,cc=c+dc; if(!inB(rr,cc))continue; const t=idx(rr,cc),tp=b[t];
if(!tp) moves.push({from:f,to:t,piece:{...p},isCapture:false,flag:'normal'});
else if(tp.c!==p.c) moves.push({from:f,to:t,piece:{...p},captured:{...tp},isCapture:true,flag:'normal'});
}
} else if(p.t==='K'){
for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++){ if(!dr&&!dc)continue;
const rr=r+dr,cc=c+dc; if(!inB(rr,cc))continue; const t=idx(rr,cc),tp=b[t];
if(!tp) moves.push({from:f,to:t,piece:{...p},isCapture:false,flag:'normal'});
else if(tp.c!==p.c) moves.push({from:f,to:t,piece:{...p},captured:{...tp},isCapture:true,flag:'normal'});
}
// castling
const home = p.c==='w'?7:0;
if(r===home&&c===4&&!inCheck(state,p.c)){
const rights = p.c==='w'?['K','Q']:['k','q'];
// kingside
if(state.castling[rights[0]] && !b[idx(home,5)] && !b[idx(home,6)]){
const rook=b[idx(home,7)];
if(rook&&rook.c===p.c&&rook.t==='R'){
if(!isAttacked(b,idx(home,5),opp(p.c))&&!isAttacked(b,idx(home,6),opp(p.c)))
moves.push({from:f,to:idx(home,6),piece:{...p},isCapture:false,flag:'castleK'});
}
}
if(state.castling[rights[1]] && !b[idx(home,3)] && !b[idx(home,2)] && !b[idx(home,1)]){
const rook=b[idx(home,0)];
if(rook&&rook.c===p.c&&rook.t==='R'){
if(!isAttacked(b,idx(home,3),opp(p.c))&&!isAttacked(b,idx(home,2),opp(p.c)))
moves.push({from:f,to:idx(home,2),piece:{...p},isCapture:false,flag:'castleQ'});
}
}
}
} else {
const sets = p.t==='B'?[[-1,-1],[-1,1],[1,-1],[1,1]] : p.t==='R'?[[-1,0],[1,0],[0,-1],[0,1]] : [[-1,-1],[-1,1],[1,-1],[1,1],[-1,0],[1,0],[0,-1],[0,1]];
for(const [dr,dc] of sets){ let rr=r+dr,cc=c+dc;
while(inB(rr,cc)){ const t=idx(rr,cc),tp=b[t];
if(!tp) moves.push({from:f,to:t,piece:{...p},isCapture:false,flag:'normal'});
else { if(tp.c!==p.c) moves.push({from:f,to:t,piece:{...p},captured:{...tp},isCapture:true,flag:'normal'}); break; }
rr+=dr;cc+=dc; } }
}
}
return moves;
}
// --- apply move (mutates state), returns undo ---
function applyMove(state,m){
const undo={castling:{...state.castling},ep:state.ep,half:state.half,full:state.full,captured:null,epCaptured:null,movedPiece:{...state.board[m.from]},rookFrom:-1,rookTo:-1,rookPiece:null};
const b=state.board; const mover=b[m.from];
// ep captured pawn
if(m.flag==='ep'){
const [tr,tc]=rc(m.to); const capR = mover.c==='w'?tr+1:tr-1;
undo.epCaptured={sq:idx(capR,tc),piece:{...b[idx(capR,tc)]}};
b[idx(capR,tc)]=null;
} else if(b[m.to]){ undo.captured={...b[m.to]}; }
b[m.from]=null;
let placed={c:mover.c,t:m.promotion||mover.t};
b[m.to]=placed;
if(m.flag==='castleK'){ const [r]=rc(m.from); const home=r; undo.rookFrom=idx(home,7);undo.rookTo=idx(home,5); undo.rookPiece={...b[undo.rookFrom]}; b[undo.rookTo]=b[undo.rookFrom]; b[undo.rookFrom]=null; }
if(m.flag==='castleQ'){ const [r]=rc(m.from); const home=r; undo.rookFrom=idx(home,0);undo.rookTo=idx(home,3); undo.rookPiece={...b[undo.rookFrom]}; b[undo.rookTo]=b[undo.rookFrom]; b[undo.rookFrom]=null; }
// castling rights
if(mover.t==='K'){ if(mover.c==='w'){state.castling.K=false;state.castling.Q=false;} else {state.castling.k=false;state.castling.q=false;} }
// rook moved from corner
const clearRight=(sq,right)=>{ if(m.from===sq) state.castling[right]=false; if(m.to===sq) state.castling[right]=false; };
clearRight(idx(7,0),'Q'); clearRight(idx(7,7),'K'); clearRight(idx(0,0),'q'); clearRight(idx(0,7),'k');
// ep square
state.ep=null;
if(mover.t==='P'&&m.flag==='double'){ const [fr,fc]=rc(m.from); const [tr]=rc(m.to); state.ep=idx((fr+tr)/2,fc); }
// halfmove
if(mover.t==='P'||m.isCapture||m.flag==='ep') state.half=0; else state.half++;
// fullmove
if(state.turn==='b') state.full++;
state.turn=opp(state.turn);
return undo;
}
function unapplyMove(state,m,undo){
state.turn=opp(state.turn);
state.castling=undo.castling; state.ep=undo.ep; state.half=undo.half; state.full=undo.full;
const b=state.board;
b[m.from]=undo.movedPiece?{...undo.movedPiece}:null;
b[m.to]=null;
if(m.flag==='ep'&&undo.epCaptured){ b[undo.epCaptured.sq]={...undo.epCaptured.piece}; }
else if(undo.captured){ b[m.to]={...undo.captured}; }
if((m.flag==='castleK'||m.flag==='castleQ')&&undo.rookFrom>=0){ b[undo.rookFrom]={...undo.rookPiece}; b[undo.rookTo]=null; }
}
// --- legal moves (generator-based validation) ---
function legalMoves(state, from=null){
const pseudos=pseudoMoves(state,from);
const out=[];
for(const m of pseudos){
const moverColor=state.turn;
const u=applyMove(state,m);
const k=kingSq(state.board,moverColor);
const ok = k>=0 ? !isAttacked(state.board,k,opp(moverColor)) : true;
unapplyMove(state,m,u);
if(ok) out.push(m);
}
return out;
}
// --- status ---
function gameStatus(state){
const moves=legalMoves(state);
const check=inCheck(state,state.turn);
if(moves.length===0){
if(check) return {over:true,result:state.turn==='w'?'0-1':'1-0',reason:'checkmate',inCheck:true};
return {over:true,result:'1/2-1/2',reason:'stalemate',inCheck:false};
}
if(state.half>=100 && moves.length>0) return {over:true,result:'1/2-1/2',reason:'fifty-move rule',inCheck:check};
if(moves.length>0 && isInsufficient(state.board)) return {over:true,result:'1/2-1/2',reason:'insufficient material',inCheck:check};
return {over:false,inCheck:check,moves: moves.length};
}
function isInsufficient(board){
let minors=0;
for(let i=0;i<64;i++){ const p=board[i]; if(!p)continue;
if(p.t==='P'||p.t==='R'||p.t==='Q') return false;
if(p.t==='B'||p.t==='N') minors++;
}
return minors<=1; // KvsK (0), KBvsK / KNvsK (1)
}
// --- FEN ---
function toFEN(state){
let rows=[];
for(let r=0;r<8;r++){ let s='',empty=0;
for(let c=0;c<8;c++){ const p=state.board[idx(r,c)];
if(!p) empty++;
else { if(empty){s+=empty;empty=0;} s+= p.c==='w'?p.t:p.t.toLowerCase(); } }
if(empty)s+=empty; rows.push(s); }
let cast=''; if(state.castling.K)cast+='K'; if(state.castling.Q)cast+='Q'; if(state.castling.k)cast+='k'; if(state.castling.q)cast+='q'; if(!cast)cast='-';
return `${rows.join('/')} ${state.turn} ${cast} ${state.ep==null?'-':sqName(state.ep)} ${state.half} ${state.full}`;
}
function parseFEN(fen){
const parts=fen.trim().split(/\s+/);
const rows=parts[0].split('/');
const board=new Array(64).fill(null);
for(let r=0;r<8;r++){ let c=0;
for(const ch of rows[r]){ if(/\d/.test(ch)){ c+=+ch; } else { board[idx(r,c)]={c:ch===ch.toUpperCase()?'w':'b',t:ch.toUpperCase()}; c++; } } }
const castStr=parts[2]||'-';
return { board, turn:parts[1]||'w',
castling:{K:castStr.includes('K'),Q:castStr.includes('Q'),k:castStr.includes('k'),q:castStr.includes('q')},
ep:(!parts[3]||parts[3]==='-')?null:sqIndex(parts[3]), half:+(parts[4]||0), full:+(parts[5]||1) };
}
// --- SAN ---
function moveToSAN(state, m){
if(m.flag==='castleK') return 'O-O';
if(m.flag==='castleQ') return 'O-O-O';
const b=state.board; const p=b[m.from];
let san='';
const dest=sqName(m.to);
if(p.t==='P'){
if(m.isCapture) san+=FILES[m.from%8]+'x';
san+=dest;
if(m.promotion) san+='='+m.promotion;
if(m.flag==='ep') san+=' e.p.';
} else {
san+= p.t==='N'?'N':p.t;
// disambiguation
const others=legalMoves(state).filter(x=>x.to===m.to&&x.from!==m.from&&state.board[x.from]&&state.board[x.from].t===p.t);
if(others.length){
const [fr,fc]=rc(m.from);
const sameFile=others.some(x=>rc(x.from)[1]===fc);
const sameRank=others.some(x=>rc(x.from)[0]===fr);
if(!sameFile) san+=FILES[fc];
else if(!sameRank) san+=(8-fr);
else san+=FILES[fc]+(8-fr);
}
if(m.isCapture) san+='x';
san+=dest;
}
// check / mate suffix (needs post-move state)
const u=applyMove(state,m);
const st=gameStatus(state);
unapplyMove(state,m,u);
if(st.reason==='checkmate') san+='#';
else if(st.inCheck) san+='+';
return san;
}
function uciOf(m){ return sqName(m.from)+sqName(m.to)+(m.promotion?m.promotion.toLowerCase():''); }
function findMoveByUCI(state, uci){
uci=uci.trim();
if(uci.length<4) return null;
const from=sqIndex(uci.slice(0,2)), to=sqIndex(uci.slice(2,4));
const promo=uci.length>4?uci[4].toUpperCase():null;
const cands=legalMoves(state,from).filter(m=>m.to===to);
if(!cands.length) return null;
if(promo) return cands.find(m=>m.promotion===promo)||null;
const nonPromo=cands.filter(m=>!m.promotion);
if(nonPromo.length) return nonPromo[0];
return cands[0]; // promotion default queen handled by UI picker; engine picks queen
}
/* ============================================================
4. AI — negamax + alpha-beta + move ordering + quiescence-lite
============================================================ */
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,-5,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]};
function evaluateBoard(state){
let s=0;
for(let i=0;i<64;i++){ const p=state.board[i]; if(!p)continue;
const v=CONFIG.VAL[p.t]+pstVal(p.t,i,p.c);
s += p.c==='w'? v : -v;
}
return s;
}
// mirror helper: black PST index = (7-r)*8+c
function pstVal(t,i,color){ const r=Math.floor(i/8),c=i%8; const mi=color==='w'?i:(7-r)*8+c; return PST[t][mi]; }
function orderMoves(moves,state){
for(const m of moves){
let s=0;
if(m.promotion) s+=900;
if(m.isCapture||m.flag==='ep'){
const victim = m.flag==='ep'?CONFIG.VAL.P : (state.board[m.to]?CONFIG.VAL[state.board[m.to].t]:0);
const attacker=CONFIG.VAL[state.board[m.from].t];
s+=10000+victim-attacker/10;
}
if(m.flag==='castleK'||m.flag==='castleQ') s+=50;
m._s=s;
}
moves.sort((a,b)=>b._s-a._s);
return moves;
}
let nodes=0;
function quiesce(state,alpha,beta,depth){
nodes++;
// NOTE: stand-pat must be from the side-to-move's perspective (negamax convention)
const stm=state.turn;
const stand=(stm==='w'?evaluateBoard(state):-evaluateBoard(state));
if(depth<=0) return stand;
if(stand>=beta) return beta;
if(stand>alpha) alpha=stand;
const caps=orderMoves(legalMoves(state).filter(m=>m.isCapture||m.promotion),state);
for(const m of caps){
const u=applyMove(state,m);
const sc=-quiesce(state,-beta,-alpha,depth-1);
unapplyMove(state,m,u);
if(sc>=beta) return beta;
if(sc>alpha) alpha=sc;
}
return alpha;
}
function negamax(state,depth,alpha,beta,color){
nodes++;
const st=gameStatus(state);
if(st.over){
if(st.reason==='checkmate') return -100000-depth; // prefer faster mate
return 0;
}
if(depth<=0) return quiesce(state,alpha,beta,CONFIG.QUIESCE_DEPTH);
const moves=orderMoves(legalMoves(state),state);
let best=-Infinity;
for(const m of moves){
const u=applyMove(state,m);
const sc=-negamax(state,depth-1,-beta,-alpha,opp(color));
unapplyMove(state,m,u);
if(sc>best)best=sc;
if(sc>alpha)alpha=sc;
if(alpha>=beta)break;
}
return best;
}
function findBestMove(state,depth){
nodes=0;
const turn=state.turn;
const moves=orderMoves(legalMoves(state),state);
if(!moves.length) return null;
if(moves.length===1) return {move:moves[0],score:0,nodes:1};
let best=null,bestScore=-Infinity,alpha=-Infinity;
for(const m of moves){
const u=applyMove(state,m);
const sc=-negamax(state,depth-1,-Infinity,-alpha,opp(turn));
unapplyMove(state,m,u);
if(sc>bestScore){bestScore=sc;best=m;}
if(sc>alpha)alpha=sc;
}
return {move:best,score:bestScore,nodes};
}
// Chunked async search: one root-move per time slice so UI never freezes
function findBestMoveAsync(state,depth,onDone){
const moves=orderMoves(legalMoves(state),state);
if(!moves.length){ onDone(null); return; }
if(moves.length===1){ onDone({move:moves[0],score:0,nodes:1}); return; }
nodes=0;
let i=0,best=moves[0],bestScore=-Infinity,alpha=-Infinity;
const turn=state.turn;
function step(){
const t0=performance.now();
while(i<moves.length){
const m=moves[i++];
const u=applyMove(state,m);
const sc=-negamax(state,depth-1,-Infinity,-alpha,opp(turn));
unapplyMove(state,m,u);
if(sc>bestScore){bestScore=sc;best=m;}
if(sc>alpha)alpha=sc;
if(performance.now()-t0>40) break; // yield to UI
}
if(i<moves.length){ setTimeout(step,0); }
else onDone({move:best,score:bestScore,nodes});
}
step();
}
/* ============================================================
5. GAME CONTROLLER + PERSISTENCE
============================================================ */
const Game = {
state: newState(),
history: [], // {move, san, undo}
sanList: [],
flipped:false,
playerColor:'w', // 'w','b','both'
depth: CONFIG.AI_DEPTH_DEFAULT,
selected:null,
legalForSelected:[],
lastMove:null,
gameOver:null,
aiThinking:false,
pendingPromo:null,
reset(){
this.state=newState(); this.history=[]; this.sanList=[]; this.selected=null;
this.legalForSelected=[]; this.lastMove=null; this.gameOver=null; this.pendingPromo=null;
this.save(); renderAll(); maybeAI();
},
save(){
try{
localStorage.setItem(CONFIG.SAVE_KEY, JSON.stringify({
state:{board:this.state.board,turn:this.state.turn,castling:this.state.castling,ep:this.state.ep,half:this.state.half,full:this.state.full},
sanList:this.sanList,
uciList:this.history.map(h=>uciOf(h.move)),
playerColor:this.playerColor, depth:this.depth, flipped:this.flipped
}));
}catch(e){}
},
load(){
try{
const raw=localStorage.getItem(CONFIG.SAVE_KEY); if(!raw) return false;
const d=JSON.parse(raw); if(!d||!d.state||!d.state.board) return false;
this.state={board:d.state.board.map(p=>p?{c:p.c,t:p.t}:null),turn:d.state.turn,castling:d.state.castling,ep:d.state.ep,half:d.state.half,full:d.state.full};
this.playerColor=d.playerColor||'w'; this.depth=d.depth||2; this.flipped=!!d.flipped;
// rebuild history by replaying UCI
this.history=[]; this.sanList=[];
const tmp=newState();
if(d.uciList&&d.uciList.length){
let ok=true;
for(const u of d.uciList){ const m=findMoveByUCI(tmp,u); if(!m){ok=false;break;} const san=moveToSAN(tmp,m); const undo=applyMove(tmp,m); this.history.push({move:m,san,undo}); this.sanList.push(san); }
if(ok){ this.state=tmp; }
else { this.history=[]; this.sanList=d.sanList||[]; }
}
this.lastMove=this.history.length?this.history[this.history.length-1].move:null;
this.updateStatus();
return true;
}catch(e){ return false; }
},
tryMove(from,to,promoChoice){
if(this.gameOver) return false;
const cands=legalMoves(this.state,from).filter(m=>m.to===to);
if(!cands.length) return false;
let chosen;
if(cands[0].promotion){
if(!promoChoice){ this.pendingPromo={from,to}; showPromoPicker(from,to,this.state.turn); return 'promo'; }
chosen=cands.find(m=>m.promotion===promoChoice)||cands.find(m=>m.promotion==='Q');
} else chosen=cands[0];
this.doMove(chosen);
return true;
},
doMove(m){
const san=moveToSAN(this.state,m);
const undo=applyMove(this.state,m);
this.history.push({move:{...m},san,undo});
this.sanList.push(san);
this.lastMove={...m}; this.selected=null; this.legalForSelected=[]; this.pendingPromo=null;
hidePromo();
// sounds
const st=gameStatus(this.state);
if(st.over){ this.gameOver=st; Sound.end(); }
else if(st.inCheck) Sound.check();
else if(m.isCapture||m.flag==='ep') Sound.capture();
else if(m.flag==='castleK'||m.flag==='castleQ') Sound.castle();
else Sound.move();
if(!st.over) this.gameOver=null; else this.gameOver=st;
this.updateStatus();
this.save(); renderAll();
maybeAI();
},
undo(){
this.aiThinking=false; $('think').classList.remove('show');
if(!this.history.length) return;
const take=()=>{
const h=this.history.pop(); this.sanList.pop();
unapplyMove(this.state,h.move,h.undo);
};
if(this.playerColor==='both'){ take(); }
else {
// take back AI + player (2 ply) if possible
take();
if(this.history.length && !this.gameOver) {
// if it's now AI's turn still (e.g., only 1 move played), take one is enough; else take second
const aiColor = opp(this.playerColor);
if(this.state.turn===aiColor || this.history.length===0){ /* after 1 take, turn flipped; if AI already moved this restores player turn */ }
// If after single take it's still not player's turn, take another
if(this.state.turn!==this.playerColor && this.history.length) take();
}
}
this.gameOver=null;
this.lastMove=this.history.length?this.history[this.history.length-1].move:null;
this.selected=null; this.legalForSelected=[];
this.updateStatus(); this.save(); renderAll();
},
updateStatus(){
const st=gameStatus(this.state);
if(st.over){ this.gameOver=st; }
return st;
}
};
/* ============================================================
6. RENDERING + INTERACTION
============================================================ */
const $=id=>document.getElementById(id);
const boardEl=$('board'), movesTable=$('movesTable');
function displayIdx(r,c){ // board-grid position given logical r,c + flip
if(!Game.flipped) return idx(r,c);
return idx(7-r,7-c);
}
function logicalFromDisplay(di){
if(!Game.flipped) return di;
const [r,c]=rc(di); return idx(7-r,7-c);
}
function buildBoard(){
boardEl.innerHTML='';
for(let d=0;d<64;d++){
const div=document.createElement('div');
div.className='sq'; div.dataset.d=d;
div.innerHTML='<span class="piece"></span>';
boardEl.appendChild(div);
}
boardEl.querySelectorAll('.sq').forEach(sq=>{
sq.addEventListener('pointerdown', onDown);
});
window.addEventListener('pointermove', onDragMove);
window.addEventListener('pointerup', onUp);
}
let drag=null;
function onDown(e){
if(Game.aiThinking) return;
const d=+e.currentTarget.dataset.d;
const sq=logicalFromDisplay(d);
const piece=Game.state.board[sq];
const myTurn = Game.playerColor==='both' || Game.state.turn===Game.playerColor;
if(Game.selected==null){
if(piece&&myTurn&&piece.c===Game.state.turn){
select(sq);
startDrag(e,sq);
}
} else {
// attempt move to sq
if(sq===Game.selected){ deselect(); return; }
const res=Game.tryMove(Game.selected,sq);
if(res==='promo'){ startDragCancel(); return; }
if(res){ deselect(); return; }
// reselect
if(piece&&piece.c===Game.state.turn&&myTurn){ select(sq); startDrag(e,sq); }
else deselect();
}
if(e.pointerId!=null){ try{e.currentTarget.setPointerCapture(e.pointerId);}catch(_){} }
}
function startDrag(e,sq){
const p=Game.state.board[sq]; if(!p) return;
drag={from:sq,el:$('dragPiece')};
drag.el.textContent=GLYPH[p.t];
drag.el.style.color=p.c==='w'?'#faf5e8':'#15110c';
drag.el.style.textShadow=p.c==='w'?'0 0 2px #000,0 2px 0 #000,-1px 0 0 #000,1px 0 0 #000':'0 0 2px rgba(255,255,255,.35)';
drag.el.style.display='block';
moveDrag(e);
}
function moveDrag(e){ if(drag){ drag.el.style.left=e.clientX+'px'; drag.el.style.top=e.clientY+'px'; } }
function onDragMove(e){ moveDrag(e);
boardEl.querySelectorAll('.sq').forEach(s=>s.classList.remove('hover-target'));
if(drag&&e.clientX!=null){
const el=document.elementFromPoint(e.clientX,e.clientY);
const sqEl=el&&el.closest?el.closest('.sq'):null;
if(sqEl&&boardEl.contains(sqEl)) sqEl.classList.add('hover-target');
}
}
function onUp(e){
if(!drag) return;
const el=document.elementFromPoint(e.clientX,e.clientY);
const sqEl=el&&el.closest?el.closest('.sq'):null;
const from=drag.from;
$('dragPiece').style.display='none';
boardEl.querySelectorAll('.sq').forEach(s=>s.classList.remove('hover-target'));
const d=drag; drag=null;
if(!sqEl||!boardEl.contains(sqEl)) { renderAll(); return; }
const to=logicalFromDisplay(+sqEl.dataset.d);
if(to===from){ return; } // click handled by selection already
const myTurn = Game.playerColor==='both' || Game.state.turn===Game.playerColor;
if(!myTurn) return;
Game.selected=from;
Game.legalForSelected=legalMoves(Game.state,from);
const res=Game.tryMove(from,to);
if(!res){ select(from); Sound.notify(); }
}
function startDragCancel(){ if(drag){$('dragPiece').style.display='none'; drag=null;} }
function select(sq){ Game.selected=sq; Game.legalForSelected=legalMoves(Game.state,sq); renderAll(); }
function deselect(){ Game.selected=null; Game.legalForSelected=[]; renderAll(); }
function renderAll(){
renderBoard(); renderSide(); renderCoords();
$('selColor').value=Game.playerColor; $('selDepth').value=String(Game.depth);
$('fenPreview').textContent=toFEN(Game.state);
}
function renderCoords(){
const f=Game.flipped?'hgfedcba':'abcdefgh';
$('topCoords').innerHTML=[...f].map(x=>`<span>${x}</span>`).join('');
$('botCoords').innerHTML=[...f].map(x=>`<span>${x}</span>`).join('');
}
function renderBoard(){
const st=gameStatus(Game.state);
const checkSq = st.inCheck? kingSq(Game.state.board,Game.state.turn):-1;
for(let d=0;d<64;d++){
const el=boardEl.children[d];
const sq=logicalFromDisplay(d);
const [r,c]=rc(sq);
const isLight=(r+c)%2===0;
el.className='sq '+(isLight?'light':'dark');
// last move
if(Game.lastMove&&(Game.lastMove.from===sq||Game.lastMove.to===sq)) el.classList.add('last');
if(Game.selected===sq) el.classList.add('selected');
if(sq===checkSq) el.classList.add('check');
const p=Game.state.board[sq];
const pe=el.querySelector('.piece');
if(p){ pe.textContent=GLYPH[p.t]; pe.className='piece '+p.c; pe.style.visibility=(drag&&drag.from===sq)?'hidden':'visible'; }
else { pe.textContent=''; }
// remove old hints
el.querySelectorAll('.hint-dot,.hint-ring').forEach(n=>n.remove());
if(Game.selected!=null){
const m=Game.legalForSelected.find(m=>m.to===sq);
if(m){
const dot=document.createElement('div');
if(m.isCapture||Game.state.board[sq]){ dot.className='hint-ring'; }
else dot.className='hint-dot';
el.appendChild(dot);
}
}
}
// turn + banner
const dot=$('turnDot');
dot.className='turn-dot'+(Game.state.turn==='b'?' b':'');
let txt = Game.state.turn==='w'?'White to move':'Black to move';
if(st.over){
if(st.reason==='checkmate') txt=`Checkmate — ${Game.state.turn==='w'?'Black':'White'} wins`;
else if(st.reason==='stalemate') txt='Stalemate — draw';
else txt=`Draw — ${st.reason}`;
} else if(st.inCheck) txt+=` — <b>check!</b>`;
if(Game.playerColor!=='both'&&!st.over) txt+=` (you: ${Game.playerColor==='w'?'White':'Black'})`;
$('statusText').innerHTML=txt;
const b=$('banner');
if(st.over){ b.classList.add('show');
$('bannerTitle').textContent = st.reason==='checkmate'?'Checkmate ♔':st.reason==='stalemate'?'Stalemate':'Draw';
$('bannerSub').textContent = st.reason==='checkmate'?`${st.result==='1-0'?'White':'Black'} wins (${st.result})`:`${st.reason} (${st.result})`;
} else b.classList.remove('show');
}
function renderSide(){
// captured trays: missing pieces
const start={P:8,N:2,B:2,R:2,Q:1};
const count={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 Game.state.board){ if(p&&p.t!=='K') count[p.c][p.t]++; }
let byW='',byB='',matW=0,matB=0;
const order=['Q','R','B','N','P'];
for(const t of order){
const missB=start[t]-count.b[t]; // black pieces captured by white
const missW=start[t]-count.w[t];
byW+=GLYPH[t].repeat(Math.max(0,missB));
byB+=GLYPH[t].repeat(Math.max(0,missW));
matW+=Math.max(0,missB)*CONFIG.VAL[t]/100; matB+=Math.max(0,missW)*CONFIG.VAL[t]/100;
}
$('capByW').textContent=byW||'—'; $('capByB').textContent=byB||'—';
const diff=matW-matB;
$('scoreW').textContent = diff>0?`up +${diff.toFixed(0)}`:'';
$('scoreB').textContent = diff<0?`up +${(-diff).toFixed(0)}`:'';
// moves table
let html='';
for(let i=0;i<Game.sanList.length;i+=2){
const n=i/2+1, w=Game.sanList[i], b=Game.sanList[i+1]||'';
const curW=(i===Game.sanList.length-1)?' class="cur"':'';
const curB=(i+1===Game.sanList.length-1)?' class="cur"':'';
html+=`<tr><td class="num">${n}.</td><td${curW}>${w}</td><td${curB}>${b}</td></tr>`;
}
movesTable.innerHTML=html||'<tr><td class="num">—</td><td>No moves yet</td><td></td></tr>';
const box=$('moves'); box.scrollTop=box.scrollHeight;
}
// --- promotion picker ---
function showPromoPicker(from,to,color){
const box=$('promo'); box.innerHTML='';
for(const t of ['Q','R','B','N']){
const btn=document.createElement('button'); btn.textContent=GLYPH[t];
btn.style.color=color==='w'?'#faf5e8':'#15110c';
btn.onclick=(e)=>{ e.stopPropagation(); Game.tryMove(from,to,t); };
box.appendChild(btn);
}
// position near target square
const dDisp = (()=>{ for(let d=0;d<64;d++) if(logicalFromDisplay(d)===to) return d; return 0; })();
const sqEl=boardEl.children[dDisp];
const wrap=boardEl.getBoundingClientRect(), r=sqEl.getBoundingClientRect(), outer=document.querySelector('.board-outer').getBoundingClientRect();
box.classList.add('show');
box.style.left=(r.left-outer.left+r.width/2-90)+'px';
box.style.top=(r.top-outer.top-10)+'px';
}
function hidePromo(){ $('promo').classList.remove('show'); }
// --- AI hookup ---
function isAITurn(){
if(Game.gameOver) return false;
if(Game.playerColor==='both') return false;
return Game.state.turn!==Game.playerColor;
}
function maybeAI(){
if(!isAITurn()){ $('think').classList.remove('show'); Game.aiThinking=false; return; }
$('think').classList.add('show');
$('thinkText').textContent=`AI thinking (depth ${Game.depth})…`;
Game.aiThinking=true;
const turnAtStart=Game.state.turn;
setTimeout(()=>{
try{
const snap=cloneState(Game.state);
findBestMoveAsync(snap,Game.depth,(res)=>{
Game.aiThinking=false;
$('think').classList.remove('show');
if(!res||!res.move){ Game.updateStatus(); renderAll(); return; }
if(Game.state.turn!==turnAtStart||!isAITurn()){ renderAll(); return; } // user undid/new-game meanwhile
// map move back to live state (find equal from/to/promo)
const live=legalMoves(Game.state).find(m=>m.from===res.move.from&&m.to===res.move.to&&(m.promotion||null)===(res.move.promotion||null));
if(live) Game.doMove(live);
else renderAll();
});
}catch(err){ Game.aiThinking=false; $('think').classList.remove('show'); console.error(err); }
}, CONFIG.THINK_DELAY_MS);
}
/* ============================================================
7. WIRING
============================================================ */
$('btnNew').onclick=()=>{ Game.reset(); };
$('btnAgain').onclick=()=>{ Game.reset(); };
$('btnUndo').onclick=()=>Game.undo();
$('btnFlip').onclick=()=>{ Game.flipped=!Game.flipped; Game.save(); renderAll(); };
$('btnSound').onclick=(e)=>{ Sound.enabled=!Sound.enabled; e.target.textContent=Sound.enabled?'🔊':'🔇'; if(Sound.enabled)Sound.move(); };
$('selColor').onchange=(e)=>{ Game.playerColor=e.target.value; Game.save(); renderAll(); maybeAI(); };
$('selDepth').onchange=(e)=>{ Game.depth=+e.target.value; Game.save(); };
$('btnResign').onclick=()=>{ if(Game.gameOver)return; const winner=Game.state.turn==='w'?'Black':'White';
Game.gameOver={over:true,result:Game.state.turn==='w'?'0-1':'1-0',reason:'resignation'}; renderAll(); Sound.end(); };
$('btnHint').onclick=()=>{
if(Game.gameOver||Game.aiThinking) return;
$('think').classList.add('show'); $('thinkText').textContent='Hint…';
setTimeout(()=>{ const r=findBestMove(cloneState(Game.state),Math.min(2,Game.depth)); $('think').classList.remove('show');
if(r&&r.move){ select(r.move.from); Sound.notify(); } },50);
};
document.addEventListener('pointerdown',e=>{ const pr=$('promo'); if(pr.classList.contains('show')&&!pr.contains(e.target)&&Game.pendingPromo){ hidePromo(); Game.pendingPromo=null; Game.selected=null; Game.legalForSelected=[]; renderAll(); } },true);
/* ============================================================
8. DEBUG INTERFACE (headless probing)
============================================================ */
window.game={
fen:()=>toFEN(Game.state),
setFen:(f)=>{ Game.state=parseFEN(f); Game.history=[]; Game.sanList=[]; Game.lastMove=null; Game.gameOver=null; Game.updateStatus(); Game.save(); renderAll(); return toFEN(Game.state); },
move:(uci)=>{ const m=findMoveByUCI(Game.state,uci); if(!m) return false; Game.selected=null; Game.doMove(m); return true; },
legalMoves:(sq)=>{ if(sq){ const i=(typeof sq==='string')?sqIndex(sq):sq; return legalMoves(Game.state,i).map(uciOf); } return legalMoves(Game.state).map(uciOf); },
status:()=>gameStatus(Game.state),
perft:(d)=>perft(Game.state,d),
state:()=>Game.state, reset:()=>Game.reset(), undo:()=>Game.undo(),
setMode:(m)=>{ Game.playerColor=m; Game.aiThinking=false; try{$('think').classList.remove('show');}catch(_){} Game.save(); renderAll(); },
aiPick:(d)=>{ const r=findBestMove(cloneState(Game.state),d||Game.depth); return r&&r.move?uciOf(r.move):null; },
san:()=>Game.sanList.slice()
};
function perft(state,depth){
if(depth===0) return 1;
let n=0; const ms=legalMoves(state);
for(const m of ms){ const u=applyMove(state,m); n+=perft(state,depth-1); unapplyMove(state,m,u); }
return n;
}
/* boot */
buildBoard();
const had=Game.load();
Game.updateStatus();
renderAll();
if(!had) Game.save();
if(isAITurn()) maybeAI();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.3-contributor","provider":"openrouter","persona":"full","sessionId":"a2fc4043-9f1e-4c2a-b92f-2715796fdfc6","tokensIn":4633167,"tokensOut":54396,"tokensTotal":4687563,"cost":0.05522776999999999,"turns":69,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-09-03T04:39:19.962Z"} -->