radical-chess/client/app.js
maverick 4f2676df1e Radical Chess — real-time online chess on Cloudflare
Play by shareable link, vs the Radical Bot, or pass-and-play. Server-authoritative
moves (chess.js) in a per-game Durable Object, clocks with idle flag-fall via DO
alarms, draw/resign/rematch/chat, D1 game archive, custom board UI (drag + click).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:29:02 +10:00

667 lines
30 KiB
JavaScript

import { Chess } from 'chess.js';
// ---------------------------------------------------------------------------
// Radical Chess — client SPA
// ---------------------------------------------------------------------------
const GLYPH = {
wk: '♚', wq: '♛', wr: '♜', wb: '♝', wn: '♞', wp: '♟',
bk: '♚', bq: '♛', br: '♜', bb: '♝', bn: '♞', bp: '♟',
};
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const app = document.getElementById('app');
function el(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstChild; }
function fmtClock(ms) {
if (ms == null) return '∞';
ms = Math.max(0, ms);
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
if (m >= 60) { const h = Math.floor(m / 60); return `${h}:${String(m % 60).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; }
const tenths = ms < 10000 ? '.' + Math.floor((ms % 1000) / 100) : '';
return `${m}:${String(sec).padStart(2, '0')}${tenths}`;
}
function toast(msg) {
document.querySelectorAll('.toast').forEach((t) => t.remove());
const t = el(`<div class="toast">${msg}</div>`);
document.body.appendChild(t);
setTimeout(() => t.remove(), 2600);
}
function sidFor(id) {
const key = 'rc_sid_' + id;
let v = localStorage.getItem(key);
if (!v) { v = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)) + 'x'; localStorage.setItem(key, v); }
return v;
}
const myName = () => localStorage.getItem('rc_name') || 'Anonymous';
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function navigate(path) { history.pushState({}, '', path); route(); }
window.addEventListener('popstate', route);
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href^="/"]');
if (a && !a.target) { e.preventDefault(); navigate(a.getAttribute('href')); }
});
function route() {
const p = location.pathname;
if (p.startsWith('/g/')) return GameView(p.slice(3).split('?')[0]);
if (p === '/bot') return LocalView('bot');
if (p === '/local') return LocalView('local');
if (p === '/watch') return WatchView();
return HomeView();
}
// ---------------------------------------------------------------------------
// Home
// ---------------------------------------------------------------------------
const TIME_CONTROLS = [
{ base: 60, inc: 0, label: '1+0', tag: 'Bullet' },
{ base: 120, inc: 1, label: '2+1', tag: 'Bullet' },
{ base: 180, inc: 0, label: '3+0', tag: 'Blitz' },
{ base: 300, inc: 3, label: '5+3', tag: 'Blitz' },
{ base: 600, inc: 5, label: '10+5', tag: 'Rapid' },
{ base: 900, inc: 10, label: '15+10', tag: 'Rapid' },
{ base: 1800, inc: 0, label: '30+0', tag: 'Classical' },
{ base: 0, inc: 0, label: '∞', tag: 'Unlimited' },
];
function HomeView() {
let tcIdx = 3;
let color = 'random';
let botDiff = 2;
app.innerHTML = '';
const view = el(`
<div class="home">
<div class="hero">
<h1>Play <span class="g">radical</span> chess.</h1>
<p>Real-time online chess. Challenge a friend by link, take on the bot, or pass-and-play. No account, no fluff.</p>
</div>
<div class="panels">
<div class="card" id="online-card">
<h2>Play a friend</h2>
<div class="sub">Pick a time control, create a game, share the link.</div>
<div class="tc-grid" id="tcgrid"></div>
<div class="seg" id="colorseg">
<button data-c="white" class="on">♔ White</button>
<button data-c="random">⚄ Random</button>
<button data-c="black">♚ Black</button>
</div>
<button class="btn primary" id="create">Create game</button>
</div>
<div class="card">
<h2>Play the bot</h2>
<div class="sub">Radical Bot — a homegrown engine. Choose your poison.</div>
<div class="diff" id="diff">
<button data-d="1">Easy</button>
<button data-d="2" class="on">Medium</button>
<button data-d="3">Hard</button>
</div>
<div class="seg" id="botcolor">
<button data-c="white" class="on">♔ White</button>
<button data-c="black">♚ Black</button>
</div>
<button class="btn primary" id="playbot">Play bot</button>
<div style="height:10px"></div>
<button class="btn ghost" id="playlocal">Pass &amp; play (local)</button>
</div>
</div>
</div>`);
app.appendChild(view);
const grid = view.querySelector('#tcgrid');
TIME_CONTROLS.forEach((tc, i) => {
const b = el(`<div class="tc ${i === tcIdx ? 'on' : ''}"><b>${tc.label}</b><span>${tc.tag}</span></div>`);
b.onclick = () => { tcIdx = i; grid.querySelectorAll('.tc').forEach((x) => x.classList.remove('on')); b.classList.add('on'); };
grid.appendChild(b);
});
view.querySelector('#colorseg').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
view.querySelectorAll('#colorseg button').forEach((x) => x.classList.remove('on'));
b.classList.add('on'); color = b.dataset.c;
};
view.querySelector('#diff').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
view.querySelectorAll('#diff button').forEach((x) => x.classList.remove('on'));
b.classList.add('on'); botDiff = +b.dataset.d;
};
let botColor = 'white';
view.querySelector('#botcolor').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
view.querySelectorAll('#botcolor button').forEach((x) => x.classList.remove('on'));
b.classList.add('on'); botColor = b.dataset.c;
};
view.querySelector('#create').onclick = async (e) => {
e.target.disabled = true; e.target.textContent = 'Creating…';
const tc = TIME_CONTROLS[tcIdx];
try {
const r = await fetch('/api/new', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ base: tc.base, inc: tc.inc }) });
const { id } = await r.json();
let c = color;
if (c === 'random') c = Math.random() < 0.5 ? 'white' : 'black';
navigate(`/g/${id}?c=${c}&host=1`);
} catch { toast('Could not create game'); e.target.disabled = false; e.target.textContent = 'Create game'; }
};
view.querySelector('#playbot').onclick = () => { sessionStorage.setItem('rc_bot', JSON.stringify({ diff: botDiff, human: botColor })); navigate('/bot'); };
view.querySelector('#playlocal').onclick = () => navigate('/local');
}
// ---------------------------------------------------------------------------
// Board rendering (shared by all views)
// ---------------------------------------------------------------------------
class BoardUI {
constructor(mount, onMove) {
this.mount = mount;
this.onMove = onMove;
this.orient = 'w';
this.selected = null;
this.chess = new Chess();
this.interactive = true;
this.lastMove = null;
this.pendingPromo = null;
this.board = el('<div class="board"></div>');
mount.appendChild(this.board);
this._bindDrag();
}
setOrientation(o) { this.orient = o; this.render(); }
load(fen, lastMove) {
try { this.chess.load(fen); } catch {}
this.lastMove = lastMove || null;
this.selected = null;
this.render();
}
squares() {
const rows = this.orient === 'w' ? [8, 7, 6, 5, 4, 3, 2, 1] : [1, 2, 3, 4, 5, 6, 7, 8];
const cols = this.orient === 'w' ? FILES : [...FILES].reverse();
const out = [];
for (const r of rows) for (const f of cols) out.push(f + r);
return out;
}
render() {
const board = this.board;
board.innerHTML = '';
const kingSq = this.chess.inCheck() ? this._kingSquare(this.chess.turn()) : null;
let legal = [];
if (this.selected) legal = this.chess.moves({ square: this.selected, verbose: true });
const legalTo = new Set(legal.map((m) => m.to));
this.squares().forEach((sq, i) => {
const file = sq[0], rank = +sq[1];
const dark = (FILES.indexOf(file) + rank) % 2 === 0;
const cell = el(`<div class="sq ${dark ? 'dark' : 'light'}" data-sq="${sq}"></div>`);
if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) cell.classList.add('last');
if (sq === this.selected) cell.classList.add('sel');
if (sq === kingSq) cell.classList.add('check');
const col = i % 8, row = Math.floor(i / 8);
if (row === 7) cell.appendChild(el(`<span class="coord file">${file}</span>`));
if (col === 0) cell.appendChild(el(`<span class="coord rank">${rank}</span>`));
const p = this.chess.get(sq);
if (p) {
const pieceEl = el(`<div class="piece ${p.color}">${GLYPH[p.color + p.type]}</div>`);
pieceEl.dataset.sq = sq;
cell.appendChild(pieceEl);
}
if (legalTo.has(sq)) cell.appendChild(el(`<div class="hint ${p ? 'cap' : ''}"></div>`));
board.appendChild(cell);
});
if (this.pendingPromo) this._renderPromo();
}
_kingSquare(color) {
for (const sq of this.squares()) { const p = this.chess.get(sq); if (p && p.type === 'k' && p.color === color) return sq; }
return null;
}
_clickSquare(sq) {
if (!this.interactive) return;
const p = this.chess.get(sq);
if (this.selected) {
if (sq === this.selected) { this.selected = null; this.render(); return; }
const moves = this.chess.moves({ square: this.selected, verbose: true });
const m = moves.find((x) => x.to === sq);
if (m) { this._tryMove(this.selected, sq, m); return; }
if (p && p.color === this.chess.turn()) { this.selected = sq; this.render(); return; }
this.selected = null; this.render(); return;
}
if (p && p.color === this.chess.turn()) { this.selected = sq; this.render(); }
}
_tryMove(from, to, moveInfo) {
if (moveInfo && moveInfo.promotion) { this.pendingPromo = { from, to }; this.render(); return; }
this.selected = null;
this.onMove(from, to, null);
}
_renderPromo() {
const color = this.chess.turn();
const wrap = el('<div class="promo"><div class="promo-inner"></div></div>');
const inner = wrap.querySelector('.promo-inner');
['q', 'r', 'b', 'n'].forEach((t) => {
const pc = el(`<div class="piece ${color}">${GLYPH[color + t]}</div>`);
pc.onclick = () => { const { from, to } = this.pendingPromo; this.pendingPromo = null; this.selected = null; this.onMove(from, to, t); };
inner.appendChild(pc);
});
this.board.appendChild(wrap);
}
_bindDrag() {
let ghost = null, fromSq = null;
const pointToSq = (x, y) => {
const els = document.elementsFromPoint(x, y);
const cell = els.find((e) => e.classList && e.classList.contains('sq'));
return cell ? cell.dataset.sq : null;
};
this.board.addEventListener('pointerdown', (e) => {
if (!this.interactive) return;
const pieceEl = e.target.closest('.piece');
const cell = e.target.closest('.sq');
if (!cell) return;
const sq = cell.dataset.sq;
const p = this.chess.get(sq);
// click-move first
this._clickSquare(sq);
if (!pieceEl || !p || p.color !== this.chess.turn() || !this.selected) return;
fromSq = this.selected;
e.preventDefault();
ghost = el(`<div class="piece ${p.color} drag-ghost">${GLYPH[p.color + p.type]}</div>`);
ghost.style.width = cell.offsetWidth + 'px'; ghost.style.height = cell.offsetHeight + 'px';
ghost.style.fontSize = getComputedStyle(pieceEl).fontSize;
ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
pieceEl.classList.add('dragging');
this._dragPiece = pieceEl;
});
window.addEventListener('pointermove', (e) => {
if (!ghost) return;
ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px';
});
window.addEventListener('pointerup', (e) => {
if (!ghost) return;
const target = pointToSq(e.clientX, e.clientY);
ghost.remove(); ghost = null;
if (this._dragPiece) this._dragPiece.classList.remove('dragging');
if (target && fromSq && target !== fromSq) {
const moves = this.chess.moves({ square: fromSq, verbose: true });
const m = moves.find((x) => x.to === target);
if (m) this._tryMove(fromSq, target, m);
else { this.selected = null; this.render(); }
}
fromSq = null;
});
this.board.addEventListener('click', (e) => { /* handled in pointerdown */ });
}
}
// ---------------------------------------------------------------------------
// Move-list rendering
// ---------------------------------------------------------------------------
function renderMoves(container, moves) {
container.innerHTML = '';
for (let i = 0; i < moves.length; i += 2) {
const num = i / 2 + 1;
container.appendChild(el(`<span class="num">${num}.</span>`));
container.appendChild(el(`<span class="mv ${i === moves.length - 1 ? 'cur' : ''}">${moves[i].san}</span>`));
if (moves[i + 1]) container.appendChild(el(`<span class="mv ${i + 1 === moves.length - 1 ? 'cur' : ''}">${moves[i + 1].san}</span>`));
}
container.scrollTop = container.scrollHeight;
}
// ---------------------------------------------------------------------------
// Online game view (WebSocket)
// ---------------------------------------------------------------------------
function GameView(id) {
const params = new URLSearchParams(location.search);
const wantColor = params.get('c'); // white|black (host) or null
const isHost = params.get('host') === '1';
const sid = sidFor(id);
app.innerHTML = '';
const view = el(`
<div class="game-wrap">
<div class="board-col">
<div class="playerbar" id="pb-top"></div>
<div id="board-mount"></div>
<div class="playerbar" id="pb-bot"></div>
</div>
<div class="side">
<div class="box"><div class="status-msg" id="status">Connecting…</div></div>
<div class="box" id="sharebox" style="display:none">
<h3>Invite opponent</h3>
<div class="share"><div class="link" id="sharelink"></div><button class="btn primary sm" id="copybtn">Copy invite link</button></div>
</div>
<div class="box"><h3>Moves</h3><div class="moves" id="moves"></div>
<div class="actions" id="actions"></div>
</div>
<div class="box"><h3>Chat</h3>
<div class="chat"><div class="chat-log" id="chatlog"></div>
<div class="chat-in"><input id="chatin" placeholder="Say something…" maxlength="200" /></div>
</div>
</div>
</div>
</div>`);
app.appendChild(view);
let state = null;
let mySeat = 'spec';
const board = new BoardUI(view.querySelector('#board-mount'), sendMove);
// websocket
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
let ws, closed = false, clockTimer;
function connect() {
ws = new WebSocket(`${proto}://${location.host}/ws/${id}?sid=${sid}`);
ws.onopen = () => {
const want = wantColor ? wantColor : 'random';
ws.send(JSON.stringify({ t: 'hello', name: myName(), want }));
};
ws.onmessage = (ev) => onServer(JSON.parse(ev.data));
ws.onclose = () => { if (!closed) { setStatus('Reconnecting…'); setTimeout(connect, 1200); } };
}
function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
function sendMove(from, to, promo) {
if (mySeat !== state?.you || state?.status !== 'active') return;
send({ t: 'move', from, to, promotion: promo || 'q' });
}
function onServer(m) {
if (m.t === 'error') { toast(m.m); if (state) board.load(state.fen, lastMoveOf(state)); return; }
if (m.t === 'pong') return;
if (m.t !== 'state') return;
state = m;
mySeat = m.you;
board.orient = mySeat === 'b' ? 'b' : 'w';
board.interactive = (mySeat === 'w' || mySeat === 'b') && m.status === 'active';
board.load(m.fen, lastMoveOf(m));
renderMoves(view.querySelector('#moves'), m.moves);
renderPlayers();
renderStatus();
renderActions();
renderChat();
renderShare();
startClockTick();
}
function lastMoveOf(m) { return m.moves.length ? m.moves[m.moves.length - 1] : null; }
function renderShare() {
const box = view.querySelector('#sharebox');
const waiting = state.status === 'waiting' && (mySeat === 'w' || mySeat === 'b');
box.style.display = waiting ? 'block' : 'none';
if (waiting) {
const link = `${location.origin}/g/${id}`;
view.querySelector('#sharelink').textContent = link;
view.querySelector('#copybtn').onclick = () => { navigator.clipboard.writeText(link); toast('Link copied — send it to your opponent'); };
}
}
function pbHTML(side, top) {
const p = state[side === 'w' ? 'white' : 'black'];
const name = p.name || (p.seated ? (side === 'w' ? 'White' : 'Black') : 'Waiting…');
const clockCls = 'clock' + (clockActive(side) ? ' active' : '');
return `<div class="pname"><span class="pdot ${p.seated ? 'on' : ''}"></span>${name} <span style="color:var(--muted);font-size:12px">${side === 'w' ? '♔' : '♚'}</span></div>
<div class="${clockCls}" data-clockside="${side}">${state.tc.base === 0 ? '∞' : fmtClock(state.clock[side])}</div>`;
}
function renderPlayers() {
const topSide = board.orient === 'w' ? 'b' : 'w';
const botSide = board.orient === 'w' ? 'w' : 'b';
view.querySelector('#pb-top').innerHTML = pbHTML(topSide, true);
view.querySelector('#pb-bot').innerHTML = pbHTML(botSide, false);
}
function clockActive(side) {
if (state.status !== 'active') return false;
const turn = state.fen.split(' ')[1];
return side === turn;
}
function startClockTick() {
clearInterval(clockTimer);
if (state.status !== 'active' || state.tc.base === 0) return;
const base = { w: state.clock.w, b: state.clock.b };
const turn = state.fen.split(' ')[1];
const t0 = Date.now();
clockTimer = setInterval(() => {
const elapsed = Date.now() - t0;
const live = { w: base.w, b: base.b };
live[turn] = Math.max(0, base[turn] - elapsed);
for (const s of ['w', 'b']) {
const elc = view.querySelector(`.clock[data-clockside="${s}"]`);
if (elc) { elc.textContent = fmtClock(live[s]); elc.classList.toggle('low', live[s] < 20000); }
}
}, 100);
}
function renderStatus() {
const s = view.querySelector('#status');
if (state.status === 'waiting') { s.innerHTML = mySeat === 'spec' ? 'Waiting for players…' : 'Waiting for your opponent to join…'; return; }
if (state.status === 'active') {
const turn = state.fen.split(' ')[1];
const yours = turn === mySeat;
s.innerHTML = mySeat === 'spec' ? `${turn === 'w' ? 'White' : 'Black'} to move`
: yours ? '<span style="color:var(--red)">Your move</span>' : 'Waiting for opponent…';
if (state.drawOffer && state.drawOffer !== mySeat && mySeat !== 'spec') {
s.innerHTML += `<div class="banner" style="margin-top:10px">Opponent offers a draw
<span><button class="btn sm primary" id="acc">Accept</button> <button class="btn sm ghost" id="dec">Decline</button></span></div>`;
setTimeout(() => {
view.querySelector('#acc').onclick = () => send({ t: 'draw-accept' });
view.querySelector('#dec').onclick = () => send({ t: 'draw-decline' });
}, 0);
}
return;
}
// over
const label = state.result === '1-0' ? 'White wins' : state.result === '0-1' ? 'Black wins' : 'Draw';
s.innerHTML = `${label} <span class="reason">by ${state.reason || 'game over'}</span>`;
}
function renderActions() {
const a = view.querySelector('#actions');
a.innerHTML = '';
if (mySeat === 'spec') { a.innerHTML = '<button class="btn sm ghost" id="flip">Flip board</button><button class="btn sm ghost" id="home">Home</button>'; }
else if (state.status === 'active') {
a.innerHTML = `<button class="btn sm ghost" id="draw">½ Offer draw</button><button class="btn sm ghost" id="resign">⚑ Resign</button>`;
} else if (state.status === 'over') {
const mine = state.rematch[mySeat];
a.innerHTML = `<button class="btn sm primary" id="rematch" ${mine ? 'disabled' : ''}>${mine ? 'Rematch sent…' : '↻ Rematch'}</button><button class="btn sm ghost" id="home">New game</button>`;
}
const q = (s) => view.querySelector(s);
q('#flip') && (q('#flip').onclick = () => { board.setOrientation(board.orient === 'w' ? 'b' : 'w'); renderPlayers(); });
q('#home') && (q('#home').onclick = () => { closed = true; ws && ws.close(); navigate('/'); });
q('#draw') && (q('#draw').onclick = () => { send({ t: 'draw-offer' }); toast('Draw offered'); });
q('#resign') && (q('#resign').onclick = () => { if (confirm('Resign this game?')) send({ t: 'resign' }); });
q('#rematch') && (q('#rematch').onclick = () => { send({ t: 'rematch' }); toast('Rematch offer sent'); });
}
function renderChat() {
const log = view.querySelector('#chatlog');
log.innerHTML = '';
for (const c of state.chat) log.appendChild(el(`<div class="cm ${c.seat}"><b>${escapeHtml(c.who)}:</b> ${escapeHtml(c.text)}</div>`));
log.scrollTop = log.scrollHeight;
}
const chatin = view.querySelector('#chatin');
chatin.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && chatin.value.trim()) { send({ t: 'chat', text: chatin.value.trim() }); chatin.value = ''; }
});
function setStatus(t) { const s = view.querySelector('#status'); if (s) s.textContent = t; }
window.addEventListener('popstate', () => { closed = true; ws && ws.close(); clearInterval(clockTimer); }, { once: true });
connect();
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ---------------------------------------------------------------------------
// Local + Bot view (no server)
// ---------------------------------------------------------------------------
function LocalView(mode) {
const cfg = mode === 'bot' ? JSON.parse(sessionStorage.getItem('rc_bot') || '{"diff":2,"human":"white"}') : null;
const humanColor = cfg ? cfg.human[0] : null; // 'w' or 'b' ; null => both (pass&play)
app.innerHTML = '';
const view = el(`
<div class="game-wrap">
<div class="board-col">
<div class="playerbar" id="pb-top"></div>
<div id="board-mount"></div>
<div class="playerbar" id="pb-bot"></div>
</div>
<div class="side">
<div class="box"><div class="status-msg" id="status"></div></div>
<div class="box"><h3>Moves</h3><div class="moves" id="moves"></div>
<div class="actions">
<button class="btn sm ghost" id="flip">Flip</button>
<button class="btn sm ghost" id="undo">↶ Undo</button>
<button class="btn sm ghost" id="new">New</button>
<button class="btn sm ghost" id="home">Home</button>
</div>
</div>
</div>
</div>`);
app.appendChild(view);
const chess = new Chess();
const board = new BoardUI(view.querySelector('#board-mount'), onMove);
board.orient = humanColor === 'b' ? 'b' : 'w';
let thinking = false;
function sync() {
board.load(chess.fen(), lastMv());
renderMoves(view.querySelector('#moves'), chess.history({ verbose: true }));
renderPlayers();
renderStatus();
}
function lastMv() { const h = chess.history({ verbose: true }); return h.length ? h[h.length - 1] : null; }
function onMove(from, to, promo) {
if (thinking) return;
if (mode === 'bot' && chess.turn() !== humanColor) return;
try { chess.move({ from, to, promotion: promo || 'q' }); } catch { return; }
sync();
if (mode === 'bot' && !chess.isGameOver() && chess.turn() !== humanColor) {
thinking = true; board.interactive = false;
setTimeout(() => { botMove(chess, cfg.diff); thinking = false; board.interactive = true; sync(); }, 350);
}
}
function renderPlayers() {
const names = mode === 'bot'
? { w: humanColor === 'w' ? myName() : 'Radical Bot', b: humanColor === 'b' ? myName() : 'Radical Bot' }
: { w: 'White', b: 'Black' };
const top = board.orient === 'w' ? 'b' : 'w', bot = board.orient === 'w' ? 'w' : 'b';
const html = (s) => `<div class="pname"><span class="pdot on"></span>${names[s]} <span style="color:var(--muted);font-size:12px">${s === 'w' ? '♔' : '♚'}</span></div>`;
view.querySelector('#pb-top').innerHTML = html(top);
view.querySelector('#pb-bot').innerHTML = html(bot);
}
function renderStatus() {
const s = view.querySelector('#status');
if (chess.isGameOver()) {
let label = 'Draw', reason = 'draw';
if (chess.isCheckmate()) { label = (chess.turn() === 'w' ? 'Black' : 'White') + ' wins'; reason = 'checkmate'; }
else if (chess.isStalemate()) reason = 'stalemate';
else if (chess.isInsufficientMaterial()) reason = 'insufficient material';
else if (chess.isThreefoldRepetition()) reason = 'repetition';
else if (chess.isDraw()) reason = 'fifty-move rule';
s.innerHTML = `${label} <span class="reason">by ${reason}</span>`;
} else {
s.innerHTML = `${chess.turn() === 'w' ? 'White' : 'Black'} to move${chess.inCheck() ? ' — <span style="color:var(--red)">check</span>' : ''}`;
}
}
view.querySelector('#flip').onclick = () => { board.setOrientation(board.orient === 'w' ? 'b' : 'w'); renderPlayers(); };
view.querySelector('#undo').onclick = () => { chess.undo(); if (mode === 'bot' && chess.turn() !== humanColor) chess.undo(); sync(); };
view.querySelector('#new').onclick = () => { chess.reset(); sync(); if (mode === 'bot' && humanColor === 'b') { thinking = true; setTimeout(() => { botMove(chess, cfg.diff); thinking = false; sync(); }, 300); } };
view.querySelector('#home').onclick = () => navigate('/');
sync();
if (mode === 'bot' && humanColor === 'b') { thinking = true; board.interactive = false; setTimeout(() => { botMove(chess, cfg.diff); thinking = false; board.interactive = true; sync(); }, 400); }
}
// ---------------------------------------------------------------------------
// Radical Bot — minimax + alpha-beta with material/position eval
// ---------------------------------------------------------------------------
const PVAL = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 };
const PST_PAWN = [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];
const PST_KNIGHT = [-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];
const PST_CENTER = [-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];
function sqIndex(sq) { return (8 - +sq[1]) * 8 + FILES.indexOf(sq[0]); }
function pstFor(type) { return type === 'p' ? PST_PAWN : type === 'n' ? PST_KNIGHT : PST_CENTER; }
function evaluate(chess) {
if (chess.isCheckmate()) return chess.turn() === 'w' ? -100000 : 100000;
if (chess.isDraw() || chess.isStalemate()) return 0;
let score = 0;
const b = chess.board();
for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++) {
const p = b[r][c];
if (!p) continue;
const idx = r * 8 + c;
const pst = pstFor(p.type);
const posVal = p.color === 'w' ? pst[idx] : pst[63 - idx];
const v = PVAL[p.type] + posVal;
score += p.color === 'w' ? v : -v;
}
return score;
}
function search(chess, depth, alpha, beta) {
if (depth === 0 || chess.isGameOver()) return evaluate(chess);
const moves = chess.moves({ verbose: true });
const maximizing = chess.turn() === 'w';
let best = maximizing ? -Infinity : Infinity;
// order captures first
moves.sort((a, b) => (b.captured ? 1 : 0) - (a.captured ? 1 : 0));
for (const m of moves) {
chess.move(m);
const val = search(chess, depth - 1, alpha, beta);
chess.undo();
if (maximizing) { if (val > best) best = val; alpha = Math.max(alpha, val); }
else { if (val < best) best = val; beta = Math.min(beta, val); }
if (beta <= alpha) break;
}
return best;
}
function botMove(chess, difficulty) {
const moves = chess.moves({ verbose: true });
if (!moves.length) return;
// Easy: mostly random with capture bias
if (difficulty === 1) {
if (Math.random() < 0.35) { chess.move(moves[Math.floor(Math.random() * moves.length)]); return; }
}
const depth = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3;
const maximizing = chess.turn() === 'w';
let best = null, bestVal = maximizing ? -Infinity : Infinity;
moves.sort((a, b) => (b.captured ? 1 : 0) - (a.captured ? 1 : 0));
for (const m of moves) {
chess.move(m);
let val = search(chess, depth - 1, -Infinity, Infinity);
chess.undo();
val += (Math.random() - 0.5) * (difficulty === 3 ? 4 : 24); // jitter to vary play
if (maximizing ? val > bestVal : val < bestVal) { bestVal = val; best = m; }
}
chess.move(best || moves[0]);
}
// ---------------------------------------------------------------------------
// Watch view — recent finished games
// ---------------------------------------------------------------------------
async function WatchView() {
app.innerHTML = '';
const view = el(`<div class="list"><h1>Recent games</h1><div id="rows"><div class="empty">Loading…</div></div></div>`);
app.appendChild(view);
try {
const { games } = await (await fetch('/api/recent')).json();
const rows = view.querySelector('#rows');
if (!games || !games.length) { rows.innerHTML = '<div class="empty">No finished games yet. Be the first — go play!</div>'; return; }
rows.innerHTML = '';
for (const g of games) {
rows.appendChild(el(`<div class="grow-row">
<span>${escapeHtml(g.white_name || 'White')} <span style="color:var(--muted)">vs</span> ${escapeHtml(g.black_name || 'Black')}</span>
<span><span class="res">${g.result}</span> · ${g.moves} moves · ${escapeHtml(g.time_control || '')}</span>
</div>`));
}
} catch { view.querySelector('#rows').innerHTML = '<div class="empty">Could not load games.</div>'; }
}
// ---------------------------------------------------------------------------
route();