From 4f2676df1e1f8f74ab91df7ea9885bee9c6f69a2 Mon Sep 17 00:00:00 2001 From: maverick Date: Thu, 23 Jul 2026 21:29:02 +1000 Subject: [PATCH] =?UTF-8?q?Radical=20Chess=20=E2=80=94=20real-time=20onlin?= =?UTF-8?q?e=20chess=20on=20Cloudflare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 6 + README.md | 59 ++ client/app.js | 667 +++++++++++++ migrations/0001_init.sql | 15 + package-lock.json | 1991 ++++++++++++++++++++++++++++++++++++++ package.json | 22 + public/index.html | 29 + public/style.css | 196 ++++ src/game.js | 356 +++++++ src/index.js | 60 ++ wrangler.jsonc | 39 + 11 files changed, 3440 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client/app.js create mode 100644 migrations/0001_init.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 src/game.js create mode 100644 src/index.js create mode 100644 wrangler.jsonc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f67086b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +public/dist/ +.wrangler/ +.dev.vars +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..99c0cbd --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# ♞ Radical Chess + +Real-time online chess, live at **[chess.theradicalparty.com](https://chess.theradicalparty.com)**. +A Radical Party project — *Vibe, Vote, Veto.* + +Play a friend by shareable link, take on the homegrown **Radical Bot**, or +pass-and-play on one device. No account, no ads. + +## Features + +- **Play online** — create a game, pick a time control, share the link. Moves + stream live over WebSockets; reconnect keeps your seat. +- **Server-authoritative** — every move is validated server-side with + [chess.js](https://github.com/jhlywa/chess.js); illegal moves are rejected. +- **Clocks** — bullet → classical presets with increment, plus an unlimited mode. + Flag-fall is detected even when a player is idle (Durable Object alarms). +- **Full ruleset** — castling, en passant, promotion, checkmate, stalemate, + threefold repetition, insufficient material, fifty-move rule. +- **Draw offers, resign, rematch** (colors swap on rematch), and in-game chat. +- **Radical Bot** — a minimax + alpha-beta engine (Easy / Medium / Hard) with + piece-square evaluation, running entirely client-side. +- **Spectate** — anyone with the link watches live once both seats are taken. +- **Game archive** — finished games are stored (PGN) and listed under `/watch`. + +## Stack + +- **Cloudflare Workers** + [Hono](https://hono.dev) — HTTP + WebSocket routing +- **Durable Objects** — one instance per game holds authoritative state and + broadcasts to all sockets using WebSocket hibernation +- **D1** — finished-game archive and public feed +- **Static assets** — vanilla-JS SPA; the board is rendered from scratch + (drag-and-drop + click-to-move, no external board library) + +## Layout + +``` +src/index.js Worker entry (Hono) — /api/new, /ws/:id, /api/recent +src/game.js Game Durable Object — rules, clocks, chat, archival +client/app.js SPA: router, board UI, online/bot/local views, bot engine +public/ index.html, style.css, built app.js (public/dist) +migrations/ D1 schema +``` + +## Develop + +```bash +npm install +npm run dev # builds client, runs wrangler dev +``` + +## Deploy + +Pushing to `main` auto-deploys via the Forgejo post-receive hook +(`npm run build:client` runs automatically before `wrangler deploy`). + +```bash +npm run db:migrate # apply D1 migrations (remote) +npm run deploy # wrangler deploy +``` diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..d1af83d --- /dev/null +++ b/client/app.js @@ -0,0 +1,667 @@ +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(`
${msg}
`); + 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(` +
+
+

Play radical chess.

+

Real-time online chess. Challenge a friend by link, take on the bot, or pass-and-play. No account, no fluff.

+
+
+
+

Play a friend

+
Pick a time control, create a game, share the link.
+
+
+ + + +
+ +
+
+

Play the bot

+
Radical Bot — a homegrown engine. Choose your poison.
+
+ + + +
+
+ + +
+ +
+ +
+
+
`); + app.appendChild(view); + + const grid = view.querySelector('#tcgrid'); + TIME_CONTROLS.forEach((tc, i) => { + const b = el(`
${tc.label}${tc.tag}
`); + 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('
'); + 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(`
`); + 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(`${file}`)); + if (col === 0) cell.appendChild(el(`${rank}`)); + + const p = this.chess.get(sq); + if (p) { + const pieceEl = el(`
${GLYPH[p.color + p.type]}
`); + pieceEl.dataset.sq = sq; + cell.appendChild(pieceEl); + } + if (legalTo.has(sq)) cell.appendChild(el(`
`)); + 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('
'); + const inner = wrap.querySelector('.promo-inner'); + ['q', 'r', 'b', 'n'].forEach((t) => { + const pc = el(`
${GLYPH[color + t]}
`); + 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(`
${GLYPH[p.color + p.type]}
`); + 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(`${num}.`)); + container.appendChild(el(`${moves[i].san}`)); + if (moves[i + 1]) container.appendChild(el(`${moves[i + 1].san}`)); + } + 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(` +
+
+
+
+
+
+
+
Connecting…
+ +

Moves

+
+
+

Chat

+
+
+
+
+
+
`); + 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 `
${name} ${side === 'w' ? '♔' : '♚'}
+
${state.tc.base === 0 ? '∞' : fmtClock(state.clock[side])}
`; + } + 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 ? 'Your move' : 'Waiting for opponent…'; + if (state.drawOffer && state.drawOffer !== mySeat && mySeat !== 'spec') { + s.innerHTML += ``; + 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} by ${state.reason || 'game over'}`; + } + + function renderActions() { + const a = view.querySelector('#actions'); + a.innerHTML = ''; + if (mySeat === 'spec') { a.innerHTML = ''; } + else if (state.status === 'active') { + a.innerHTML = ``; + } else if (state.status === 'over') { + const mine = state.rematch[mySeat]; + a.innerHTML = ``; + } + 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(`
${escapeHtml(c.who)}: ${escapeHtml(c.text)}
`)); + 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(` +
+
+
+
+
+
+
+
+

Moves

+
+ + + + +
+
+
+
`); + 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) => `
${names[s]} ${s === 'w' ? '♔' : '♚'}
`; + 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} by ${reason}`; + } else { + s.innerHTML = `${chess.turn() === 'w' ? 'White' : 'Black'} to move${chess.inCheck() ? ' — check' : ''}`; + } + } + + 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(`

Recent games

Loading…
`); + app.appendChild(view); + try { + const { games } = await (await fetch('/api/recent')).json(); + const rows = view.querySelector('#rows'); + if (!games || !games.length) { rows.innerHTML = '
No finished games yet. Be the first — go play!
'; return; } + rows.innerHTML = ''; + for (const g of games) { + rows.appendChild(el(`
+ ${escapeHtml(g.white_name || 'White')} vs ${escapeHtml(g.black_name || 'Black')} + ${g.result} · ${g.moves} moves · ${escapeHtml(g.time_control || '')} +
`)); + } + } catch { view.querySelector('#rows').innerHTML = '
Could not load games.
'; } +} + +// --------------------------------------------------------------------------- +route(); diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..8959f6a --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,15 @@ +-- Finished-game archive + lightweight stats +CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + white_name TEXT, + black_name TEXT, + result TEXT, -- '1-0', '0-1', '1/2-1/2', '*' + reason TEXT, -- checkmate, resign, timeout, draw, stalemate... + pgn TEXT, + moves INTEGER DEFAULT 0, + time_control TEXT, -- e.g. '300+3' or 'unlimited' + created_at INTEGER, + ended_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_games_created ON games(created_at DESC); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cb434ba --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1991 @@ +{ + "name": "radical-chess", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "radical-chess", + "version": "0.1.0", + "dependencies": { + "chess.js": "^1.0.0-beta.8", + "hono": "^4.6.14" + }, + "devDependencies": { + "esbuild": "^0.24.0", + "wrangler": "^4.96.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", + "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", + "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", + "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", + "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", + "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chess.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", + "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", + "license": "BSD-2-Clause" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260721.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", + "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260721.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", + "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260721.1", + "@cloudflare/workerd-darwin-arm64": "1.20260721.1", + "@cloudflare/workerd-linux-64": "1.20260721.1", + "@cloudflare/workerd-linux-arm64": "1.20260721.1", + "@cloudflare/workerd-windows-64": "1.20260721.1" + } + }, + "node_modules/wrangler": { + "version": "4.113.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", + "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260721.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260721.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260721.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dcd3cfd --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "radical-chess", + "version": "0.1.0", + "private": true, + "description": "Radical Chess — real-time online chess on Cloudflare (a Radical Party project)", + "type": "module", + "scripts": { + "build:client": "esbuild client/app.js --bundle --minify --format=esm --outfile=public/dist/app.js", + "dev": "npm run build:client && wrangler dev", + "deploy": "wrangler deploy", + "db:migrate:local": "wrangler d1 migrations apply radical-chess --local", + "db:migrate": "wrangler d1 migrations apply radical-chess --remote" + }, + "dependencies": { + "chess.js": "^1.0.0-beta.8", + "hono": "^4.6.14" + }, + "devDependencies": { + "esbuild": "^0.24.0", + "wrangler": "^4.96.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..38caedf --- /dev/null +++ b/public/index.html @@ -0,0 +1,29 @@ + + + + + + Radical Chess — play chess online + + + + + + + + +
+ + + RADICALCHESS + + +
+
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..6e2a627 --- /dev/null +++ b/public/style.css @@ -0,0 +1,196 @@ +:root { + --red: #FF2D55; + --red-hot: #FF6B82; + --red-deep: #c81e3a; + --bg: #0B0B09; + --raised: #12100d; + --raised-2: #1b1813; + --cream: #F0EDE6; + --muted: #9c948a; + --gold: #c9a84c; + --line: #2a2620; + --light-sq: #ece5d6; + --dark-sq: #7a6a55; + --sel: #ff2d5566; + --last: #c9a84c55; + --check: #ff2d5599; + --ok: #4caf7d; + --mono: 'IBM Plex Mono', ui-monospace, monospace; + --display: 'Syne', system-ui, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + background: radial-gradient(1200px 600px at 70% -10%, #17140f 0%, var(--bg) 55%); + color: var(--cream); + font-family: system-ui, 'Segoe UI', Inter, sans-serif; + min-height: 100%; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } + +/* ---------- top bar ---------- */ +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 22px; border-bottom: 1px solid var(--line); + background: rgba(11,11,9,.7); backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 30; +} +.brand { display: flex; align-items: center; gap: 10px; } +.brand-mark { font-size: 26px; color: var(--red); filter: drop-shadow(0 0 10px #ff2d5566); } +.brand-word { font-family: var(--display); font-weight: 800; letter-spacing: .5px; font-size: 19px; } +.brand-accent { color: var(--red); } +.topnav { display: flex; gap: 18px; font-family: var(--mono); font-size: 13px; color: var(--muted); } +.topnav a:hover { color: var(--cream); } + +main { flex: 1; width: 100%; } +.footsig { text-align: center; color: var(--muted); font-size: 12px; padding: 18px; font-family: var(--mono); } +.footsig a { color: var(--gold); } + +/* ---------- home ---------- */ +.home { max-width: 1000px; margin: 0 auto; padding: 40px 22px 60px; } +.hero { text-align: center; margin-bottom: 38px; } +.hero h1 { font-family: var(--display); font-weight: 800; font-size: clamp(34px, 6vw, 62px); line-height: 1; } +.hero h1 .g { color: var(--red); } +.hero p { color: var(--muted); margin-top: 14px; font-size: 16px; } + +.panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; } +.card { + background: linear-gradient(180deg, var(--raised) 0%, var(--raised-2) 100%); + border: 1px solid var(--line); border-radius: 16px; padding: 22px; +} +.card h2 { font-family: var(--display); font-weight: 700; font-size: 20px; margin-bottom: 6px; } +.card .sub { color: var(--muted); font-size: 13px; margin-bottom: 16px; } + +.tc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } +.tc { + border: 1px solid var(--line); background: #0e0c08; border-radius: 10px; + padding: 12px 6px; text-align: center; cursor: pointer; transition: .12s; +} +.tc:hover { border-color: var(--red); } +.tc.on { border-color: var(--red); background: #1a0d10; box-shadow: 0 0 0 1px var(--red) inset; } +.tc b { display: block; font-family: var(--mono); font-weight: 700; font-size: 18px; } +.tc span { color: var(--muted); font-size: 11px; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + font-family: var(--display); font-weight: 700; font-size: 15px; + padding: 13px 18px; border-radius: 12px; border: 1px solid transparent; + cursor: pointer; width: 100%; transition: .14s; background: var(--raised-2); color: var(--cream); +} +.btn:hover { transform: translateY(-1px); } +.btn.primary { background: linear-gradient(180deg, var(--red) 0%, var(--red-deep) 100%); color: #fff; box-shadow: 0 6px 22px #ff2d5533; } +.btn.ghost { background: transparent; border-color: var(--line); } +.btn.ghost:hover { border-color: var(--red); } +.btn.sm { width: auto; padding: 9px 14px; font-size: 13px; } +.btn:disabled { opacity: .5; cursor: default; transform: none; } + +.seg { display: flex; gap: 8px; margin-bottom: 14px; } +.seg button { + flex: 1; padding: 10px; border-radius: 10px; border: 1px solid var(--line); + background: #0e0c08; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 13px; +} +.seg button.on { color: #fff; border-color: var(--red); background: #1a0d10; } + +.diff { display: flex; gap: 8px; margin-bottom: 14px; } +.diff button { flex: 1; padding: 9px; border-radius: 9px; border: 1px solid var(--line); background: #0e0c08; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 12px; } +.diff button.on { color: #fff; border-color: var(--gold); } + +/* ---------- game layout ---------- */ +.game-wrap { max-width: 1120px; margin: 0 auto; padding: 22px; display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; } +.board-col { display: flex; flex-direction: column; gap: 10px; } + +.playerbar { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px; } +.pname { display: flex; align-items: center; gap: 8px; font-weight: 600; } +.pdot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); } +.pdot.on { background: var(--ok); box-shadow: 0 0 8px var(--ok); } +.clock { + font-family: var(--mono); font-weight: 700; font-size: 26px; letter-spacing: 1px; + background: #0e0c08; border: 1px solid var(--line); border-radius: 10px; padding: 4px 14px; min-width: 108px; text-align: center; +} +.clock.active { border-color: var(--red); color: #fff; box-shadow: 0 0 0 1px var(--red) inset; } +.clock.low { color: var(--red-hot); } + +/* board */ +.board { + width: min(72vh, 100%); aspect-ratio: 1; margin: 0 auto; + display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); + border-radius: 10px; overflow: hidden; border: 1px solid var(--line); + box-shadow: 0 18px 60px rgba(0,0,0,.5); touch-action: none; user-select: none; +} +.sq { position: relative; display: flex; align-items: center; justify-content: center; } +.sq.light { background: var(--light-sq); } +.sq.dark { background: var(--dark-sq); } +.sq.last::after { content: ''; position: absolute; inset: 0; background: var(--last); } +.sq.sel::after { content: ''; position: absolute; inset: 0; background: var(--sel); } +.sq.check .piece { filter: drop-shadow(0 0 6px var(--red)); } +.sq .coord { position: absolute; font-size: 10px; font-family: var(--mono); opacity: .5; } +.sq .coord.file { bottom: 2px; right: 3px; } +.sq .coord.rank { top: 2px; left: 3px; } +.sq.light .coord { color: #6b5c46; } +.sq.dark .coord { color: #e6dcc8; } + +.piece { + font-size: min(9vh, 12vw); line-height: 1; cursor: grab; z-index: 2; + position: relative; width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; +} +.piece.dragging { cursor: grabbing; opacity: .35; } +.piece.w { color: #f7f4ec; text-shadow: 0 1px 0 #0007, 0 0 1px #000, 1px 0 0 #0006, -1px 0 0 #0006, 0 2px 4px #0009; } +.piece.b { color: #1a1712; text-shadow: 0 1px 0 #fff5, 0 2px 4px #0008; } +.hint { position: absolute; width: 30%; height: 30%; border-radius: 50%; background: #0003; z-index: 1; } +.sq.light .hint { background: #7a6a5555; } +.hint.cap { width: 100%; height: 100%; background: none; border-radius: 0; box-shadow: inset 0 0 0 6px #0002; } +.drag-ghost { position: fixed; pointer-events: none; z-index: 100; transform: translate(-50%, -50%); } + +/* promotion */ +.promo { position: absolute; inset: 0; background: #000a; display: flex; align-items: center; justify-content: center; z-index: 40; } +.promo-inner { display: flex; gap: 8px; background: var(--raised); padding: 14px; border-radius: 12px; border: 1px solid var(--red); } +.promo-inner .piece { font-size: 44px; cursor: pointer; width: 56px; height: 56px; border-radius: 8px; } +.promo-inner .piece:hover { background: #1a0d10; } + +/* side panel */ +.side { display: flex; flex-direction: column; gap: 16px; } +.box { background: var(--raised); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } +.box h3 { font-family: var(--mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); padding: 12px 14px 0; } +.status-msg { padding: 12px 14px; font-family: var(--display); font-weight: 700; } +.status-msg .reason { font-family: system-ui; font-weight: 400; color: var(--muted); font-size: 13px; display: block; margin-top: 2px; } + +.moves { max-height: 260px; overflow-y: auto; padding: 10px 12px; font-family: var(--mono); font-size: 13px; line-height: 1.9; } +.moves .mv { display: inline-block; min-width: 62px; padding: 1px 5px; border-radius: 4px; cursor: default; } +.moves .num { color: var(--muted); display: inline-block; min-width: 26px; } +.moves .mv.cur { background: var(--red); color: #fff; } + +.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 14px; } +.actions .btn.sm { width: 100%; } + +.share { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; } +.share .link { font-family: var(--mono); font-size: 12px; background: #0e0c08; border: 1px solid var(--line); border-radius: 8px; padding: 9px; word-break: break-all; color: var(--red-hot); } + +.chat { display: flex; flex-direction: column; height: 220px; } +.chat-log { flex: 1; overflow-y: auto; padding: 10px 12px; font-size: 13px; display: flex; flex-direction: column; gap: 5px; } +.chat-log .cm { line-height: 1.35; } +.chat-log .cm b { color: var(--gold); font-weight: 600; } +.chat-log .cm.w b { color: var(--cream); } +.chat-log .cm.b b { color: var(--muted); } +.chat-in { display: flex; border-top: 1px solid var(--line); } +.chat-in input { flex: 1; background: transparent; border: 0; color: var(--cream); padding: 11px 12px; font-family: var(--mono); font-size: 13px; outline: none; } + +.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--raised); border: 1px solid var(--red); color: var(--cream); padding: 12px 18px; border-radius: 12px; font-family: var(--mono); font-size: 13px; z-index: 80; box-shadow: 0 10px 30px #000a; } +.banner { background: #1a0d10; border: 1px solid var(--red); border-radius: 10px; padding: 10px 12px; font-size: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; } + +/* watch/list */ +.list { max-width: 720px; margin: 0 auto; padding: 30px 22px; } +.list h1 { font-family: var(--display); font-weight: 800; font-size: 30px; margin-bottom: 18px; } +.grow-row { display: flex; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; margin-bottom: 8px; background: var(--raised); font-family: var(--mono); font-size: 13px; } +.grow-row .res { color: var(--gold); } +.empty { color: var(--muted); text-align: center; padding: 40px; font-family: var(--mono); } + +@media (max-width: 900px) { + .game-wrap { grid-template-columns: 1fr; } + .board { width: min(94vw, 72vh); } + .side { order: 2; } +} diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..53727ff --- /dev/null +++ b/src/game.js @@ -0,0 +1,356 @@ +import { Chess } from 'chess.js'; + +// One Durable Object instance per game id. Holds authoritative state, +// validates every move with chess.js, and broadcasts to all sockets +// (both players + spectators) using WebSocket hibernation. + +const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +export class Game { + constructor(ctx, env) { + this.ctx = ctx; + this.env = env; + this.state = null; // lazily loaded + } + + async load() { + if (this.state) return this.state; + this.state = (await this.ctx.storage.get('state')) || null; + return this.state; + } + + fresh(id, tc) { + return { + id, + fen: START_FEN, + moves: [], // { san, from, to, color } + status: 'waiting', // waiting | active | over + result: '*', // 1-0 | 0-1 | 1/2-1/2 | * + reason: null, + white: { name: null, sid: null }, + black: { name: null, sid: null }, + tc: tc || { base: 300, inc: 3 }, // seconds; base:0 => unlimited + clock: { w: (tc?.base ?? 300) * 1000, b: (tc?.base ?? 300) * 1000, lastTs: 0 }, + drawOffer: null, // 'w' | 'b' + rematch: { w: false, b: false }, + chat: [], + createdAt: Date.now(), + endedAt: null, + }; + } + + async save() { + await this.ctx.storage.put('state', this.state); + } + + unlimited() { + return !this.state.tc || this.state.tc.base === 0; + } + + // ---- HTTP: create (init) + websocket upgrade ---- + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname.endsWith('/init')) { + await this.load(); + const body = await request.json().catch(() => ({})); + if (!this.state) { + this.state = this.fresh(body.id, body.tc); + await this.save(); + } + return Response.json({ ok: true, tc: this.state.tc, status: this.state.status }); + } + + if (url.pathname.endsWith('/connect')) { + if (request.headers.get('Upgrade') !== 'websocket') { + return new Response('expected websocket', { status: 426 }); + } + await this.load(); + if (!this.state) { + // Auto-create with defaults if someone connects to an unknown game + this.state = this.fresh(url.searchParams.get('id') || crypto.randomUUID().slice(0, 8)); + await this.save(); + } + const sid = url.searchParams.get('sid') || crypto.randomUUID(); + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + this.ctx.acceptWebSocket(server); + server.serializeAttachment({ sid }); + return new Response(null, { status: 101, webSocket: client }); + } + + return new Response('not found', { status: 404 }); + } + + role(sid) { + if (this.state.white.sid === sid) return 'w'; + if (this.state.black.sid === sid) return 'b'; + return 'spec'; + } + + remaining(side) { + const c = this.state.clock; + if (this.unlimited()) return null; + const chess = new Chess(this.state.fen); + const toMove = chess.turn(); + if (this.state.status === 'active' && side === toMove && c.lastTs) { + return Math.max(0, c[side] - (Date.now() - c.lastTs)); + } + return c[side]; + } + + snapshot(youSid) { + const s = this.state; + return { + t: 'state', + id: s.id, + fen: s.fen, + moves: s.moves, + status: s.status, + result: s.result, + reason: s.reason, + white: { name: s.white.name, seated: !!s.white.sid }, + black: { name: s.black.name, seated: !!s.black.sid }, + tc: s.tc, + clock: { w: this.remaining('w'), b: this.remaining('b') }, + drawOffer: s.drawOffer, + rematch: s.rematch, + chat: s.chat.slice(-50), + you: youSid ? this.role(youSid) : 'spec', + }; + } + + broadcast() { + for (const ws of this.ctx.getWebSockets()) { + let sid = null; + try { sid = ws.deserializeAttachment()?.sid; } catch {} + try { ws.send(JSON.stringify(this.snapshot(sid))); } catch {} + } + } + + send(ws, obj) { try { ws.send(JSON.stringify(obj)); } catch {} } + + async webSocketMessage(ws, raw) { + await this.load(); + let msg; + try { msg = JSON.parse(raw); } catch { return; } + const att = ws.deserializeAttachment() || {}; + const sid = att.sid; + + switch (msg.t) { + case 'hello': return this.onHello(ws, sid, msg); + case 'move': return this.onMove(ws, sid, msg); + case 'resign': return this.onResign(sid); + case 'draw-offer': return this.onDrawOffer(sid); + case 'draw-accept': return this.onDrawAccept(sid); + case 'draw-decline': return this.onDrawDecline(sid); + case 'rematch': return this.onRematch(sid); + case 'chat': return this.onChat(sid, msg); + case 'ping': return this.send(ws, { t: 'pong' }); + } + } + + async onHello(ws, sid, msg) { + const s = this.state; + const name = (msg.name || 'Anonymous').toString().slice(0, 24); + const want = msg.want; // 'white' | 'black' | 'random' | 'watch' + + // Restore an existing seat for this sid (reconnect) + if (s.white.sid === sid || s.black.sid === sid) { + return this.send(ws, this.snapshot(sid)); + } + + if (want && want !== 'watch' && s.status !== 'over') { + let seat = want; + if (want === 'random') { + const openW = !s.white.sid, openB = !s.black.sid; + seat = openW && openB ? (sid.charCodeAt(0) % 2 ? 'white' : 'black') + : openW ? 'white' : openB ? 'black' : null; + } + if (seat === 'white' && !s.white.sid) { s.white = { name, sid }; } + else if (seat === 'black' && !s.black.sid) { s.black = { name, sid }; } + + // Start the game once both seats are filled + if (s.white.sid && s.black.sid && s.status === 'waiting') { + s.status = 'active'; + s.clock.lastTs = Date.now(); + await this.scheduleFlagAlarm(); + } + await this.save(); + } + + this.broadcast(); + } + + async onMove(ws, sid, msg) { + const s = this.state; + if (s.status !== 'active') return this.send(ws, { t: 'error', m: 'game not active' }); + const chess = new Chess(s.fen); + const turn = chess.turn(); + const seat = this.role(sid); + if (seat !== turn) return this.send(ws, { t: 'error', m: 'not your move' }); + + let move; + try { + move = chess.move({ from: msg.from, to: msg.to, promotion: msg.promotion || 'q' }); + } catch { + return this.send(ws, { t: 'error', m: 'illegal move' }); + } + if (!move) return this.send(ws, { t: 'error', m: 'illegal move' }); + + // Clock accounting for the side that just moved + if (!this.unlimited()) { + const now = Date.now(); + const elapsed = now - s.clock.lastTs; + s.clock[turn] = Math.max(0, s.clock[turn] - elapsed); + if (s.clock[turn] <= 0) { + return this.endGame(turn === 'w' ? '0-1' : '1-0', 'timeout'); + } + s.clock[turn] += (s.tc.inc || 0) * 1000; + s.clock.lastTs = now; + } + + s.fen = chess.fen(); + s.moves.push({ san: move.san, from: move.from, to: move.to, color: turn }); + s.drawOffer = null; + + // Terminal conditions + if (chess.isCheckmate()) { + return this.endGame(turn === 'w' ? '1-0' : '0-1', 'checkmate'); + } + if (chess.isStalemate()) return this.endGame('1/2-1/2', 'stalemate'); + if (chess.isInsufficientMaterial()) return this.endGame('1/2-1/2', 'insufficient material'); + if (chess.isThreefoldRepetition()) return this.endGame('1/2-1/2', 'threefold repetition'); + if (chess.isDraw()) return this.endGame('1/2-1/2', 'fifty-move rule'); + + await this.scheduleFlagAlarm(); + await this.save(); + this.broadcast(); + } + + async onResign(sid) { + const seat = this.role(sid); + if (seat === 'spec' || this.state.status !== 'active') return; + await this.endGame(seat === 'w' ? '0-1' : '1-0', 'resignation'); + } + + async onDrawOffer(sid) { + const seat = this.role(sid); + if (seat === 'spec' || this.state.status !== 'active') return; + this.state.drawOffer = seat; + await this.save(); + this.broadcast(); + } + + async onDrawDecline(sid) { + const seat = this.role(sid); + if (seat === 'spec') return; + this.state.drawOffer = null; + await this.save(); + this.broadcast(); + } + + async onDrawAccept(sid) { + const seat = this.role(sid); + const s = this.state; + if (seat === 'spec' || !s.drawOffer || s.drawOffer === seat) return; + await this.endGame('1/2-1/2', 'draw agreed'); + } + + async onRematch(sid) { + const s = this.state; + const seat = this.role(sid); + if (seat === 'spec' || s.status !== 'over') return; + s.rematch[seat] = true; + if (s.rematch.w && s.rematch.b) { + // Swap colors, keep the same players + const w = s.white, b = s.black; + const tc = s.tc; + const id = s.id; + this.state = this.fresh(id, tc); + this.state.white = { name: b.name, sid: b.sid }; + this.state.black = { name: w.name, sid: w.sid }; + this.state.status = 'active'; + this.state.clock.lastTs = Date.now(); + await this.scheduleFlagAlarm(); + } + await this.save(); + this.broadcast(); + } + + async onChat(sid, msg) { + const seat = this.role(sid); + const s = this.state; + const who = seat === 'w' ? (s.white.name || 'White') + : seat === 'b' ? (s.black.name || 'Black') : 'Spectator'; + const text = (msg.text || '').toString().slice(0, 200).trim(); + if (!text) return; + s.chat.push({ who, seat, text, ts: Date.now() }); + if (s.chat.length > 100) s.chat = s.chat.slice(-100); + await this.save(); + this.broadcast(); + } + + async scheduleFlagAlarm() { + if (this.unlimited() || this.state.status !== 'active') { + await this.ctx.storage.deleteAlarm().catch(() => {}); + return; + } + const chess = new Chess(this.state.fen); + const side = chess.turn(); + const rem = this.remaining(side); + if (rem != null) { + await this.ctx.storage.setAlarm(Date.now() + rem + 100); + } + } + + async alarm() { + await this.load(); + if (!this.state || this.state.status !== 'active') return; + const chess = new Chess(this.state.fen); + const side = chess.turn(); + if (this.remaining(side) <= 0) { + await this.endGame(side === 'w' ? '0-1' : '1-0', 'timeout'); + } else { + await this.scheduleFlagAlarm(); + } + } + + async endGame(result, reason) { + const s = this.state; + s.status = 'over'; + s.result = result; + s.reason = reason; + s.endedAt = Date.now(); + s.drawOffer = null; + await this.ctx.storage.deleteAlarm().catch(() => {}); + await this.save(); + this.broadcast(); + await this.archive().catch(() => {}); + } + + async archive() { + if (!this.env.DB) return; + const s = this.state; + const chess = new Chess(); + for (const m of s.moves) { + try { chess.move({ from: m.from, to: m.to, promotion: 'q' }); } catch {} + } + const tc = this.unlimited() ? 'unlimited' : `${s.tc.base}+${s.tc.inc}`; + await this.env.DB.prepare( + `INSERT OR REPLACE INTO games + (id, white_name, black_name, result, reason, pgn, moves, time_control, created_at, ended_at) + VALUES (?,?,?,?,?,?,?,?,?,?)` + ).bind( + s.id, s.white.name, s.black.name, s.result, s.reason, + chess.pgn(), s.moves.length, tc, s.createdAt, s.endedAt + ).run(); + } + + async webSocketClose(ws) { + // Players keep their seat on disconnect (allows reconnect); just refresh others. + this.broadcast(); + } + + async webSocketError() {} +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..3a0512b --- /dev/null +++ b/src/index.js @@ -0,0 +1,60 @@ +import { Hono } from 'hono'; +import { Game } from './game.js'; + +export { Game }; + +const app = new Hono(); + +function gameStub(env, id) { + return env.GAME.get(env.GAME.idFromName(id)); +} + +// Create a new game; returns its id. Time control via ?base=&inc= (base=0 => unlimited) +app.post('/api/new', async (c) => { + const body = await c.req.json().catch(() => ({})); + const base = Math.max(0, Math.min(10800, parseInt(body.base ?? 300, 10) || 0)); + const inc = Math.max(0, Math.min(180, parseInt(body.inc ?? 3, 10) || 0)); + const id = crypto.randomUUID().replace(/-/g, '').slice(0, 8); + const stub = gameStub(c.env, id); + await stub.fetch('https://do/init', { + method: 'POST', + body: JSON.stringify({ id, tc: { base, inc } }), + headers: { 'content-type': 'application/json' }, + }); + return c.json({ id, tc: { base, inc } }); +}); + +// WebSocket endpoint -> routed to the game's Durable Object +app.get('/ws/:id', async (c) => { + const id = c.req.param('id'); + const sid = c.req.query('sid') || crypto.randomUUID(); + const stub = gameStub(c.env, id); + const url = new URL('https://do/connect'); + url.searchParams.set('id', id); + url.searchParams.set('sid', sid); + return stub.fetch(url, { + headers: c.req.raw.headers, + }); +}); + +// Recent finished games (public feed) +app.get('/api/recent', async (c) => { + if (!c.env.DB) return c.json({ games: [] }); + const { results } = await c.env.DB.prepare( + `SELECT id, white_name, black_name, result, reason, moves, time_control, ended_at + FROM games WHERE result != '*' ORDER BY ended_at DESC LIMIT 20` + ).all(); + return c.json({ games: results || [] }); +}); + +// Single archived game (PGN) +app.get('/api/game/:id', async (c) => { + if (!c.env.DB) return c.json({ error: 'no db' }, 404); + const row = await c.env.DB.prepare( + `SELECT * FROM games WHERE id = ?` + ).bind(c.req.param('id')).first(); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json(row); +}); + +export default app; diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..71c77d6 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "radical-chess", + "main": "src/index.js", + "compatibility_date": "2025-07-01", + "compatibility_flags": ["nodejs_compat"], + "observability": { "enabled": true }, + "build": { + "command": "npm run build:client" + }, + "routes": [ + { "pattern": "chess.theradicalparty.com", "custom_domain": true } + ], + "assets": { + "directory": "./public", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*", "/ws/*"] + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "radical-chess", + "database_id": "67326dc0-9b5d-4ae7-aaad-0d7cdfffb418", + "migrations_dir": "migrations" + } + ], + "durable_objects": { + "bindings": [ + { "name": "GAME", "class_name": "Game" } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["Game"] + } + ] +}