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>
667 lines
30 KiB
JavaScript
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 & 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|