Chess
Qwen 3.8 · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 95,222 bytes · SHA-256 9661a8442389
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wood & Slate Chess</title>
<style>
/* ── css: layout ── */
/* ============ base / layout ============ */
:root {
--light: #f0d9b5;
--dark: #b58863;
--page: #12100e;
--panel: #1c1815;
--panel-2: #241f1a;
--line: #352d26;
--text: #e8ddcc;
--text-dim: #a0917c;
--accent: #d8b06a;
--danger: #e05555;
--board-size: min(72vh, 640px);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(1200px 700px at 50% -10%, #221c16 0%, var(--page) 60%),
var(--page);
color: var(--text);
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
min-height: 100vh;
}
#app { min-height: 100vh; display: flex; flex-direction: column; }
#topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 18px; gap: 16px; flex-wrap: wrap;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #1d1916, #171310);
position: sticky; top: 0; z-index: 30;
}
#logo {
font-size: 21px; letter-spacing: 0.06em; color: var(--accent);
display: flex; align-items: center; gap: 8px; font-weight: 600;
}
#controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ctl { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); }
.ctl select, #controls button {
font-family: inherit; font-size: 13px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--line); border-radius: 6px;
padding: 5px 9px; cursor: pointer;
}
#controls button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
#controls button:disabled { opacity: 0.4; cursor: default; }
#btn-flip { font-size: 15px; }
#layout {
flex: 1; display: flex; align-items: flex-start; justify-content: center;
gap: 22px; padding: 20px 16px 32px; flex-wrap: wrap;
}
#board-zone {
position: relative;
width: var(--board-size);
}
#board {
width: var(--board-size);
height: var(--board-size);
border: 10px solid #4a3524;
border-radius: 6px;
box-shadow: 0 18px 50px rgba(0,0,0,0.55), 0 0 0 1px #2c2015, inset 0 0 24px rgba(0,0,0,0.35);
background: var(--dark);
position: relative;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#panel {
width: 300px;
display: flex; flex-direction: column; gap: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.panel-block { border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); padding: 10px; }
.panel-title {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--text-dim); margin-bottom: 8px;
}
.turn {
display: flex; align-items: center; gap: 9px;
font-size: 15px; padding: 10px 12px;
border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2);
}
.turn-dot {
width: 14px; height: 14px; border-radius: 50%; flex: none;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4) inset;
}
.turn-dot.w { background: #f5f0e6; }
.turn-dot.b { background: #262019; box-shadow: 0 0 0 1px #55483a inset; }
.tray-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.tray-name { font-size: 11px; color: var(--text-dim); width: 96px; flex: none; }
.tray { display: flex; flex-wrap: wrap; gap: 1px; font-size: 19px; line-height: 1.1; min-height: 22px; }
.tray .adv { color: var(--accent); font-size: 12px; margin-left: 5px; }
.move-list {
height: 236px; overflow-y: auto; font-size: 13.5px;
font-variant-numeric: tabular-nums;
scrollbar-width: thin; scrollbar-color: #4a3c2e transparent;
}
.move-list::-webkit-scrollbar { width: 8px; }
.move-list::-webkit-scrollbar-thumb { background: #4a3c2e; border-radius: 4px; }
.move-row { display: flex; gap: 6px; padding: 2px 4px; border-radius: 4px; }
.move-row:nth-child(odd) { background: rgba(255,255,255,0.03); }
.move-num { color: var(--text-dim); width: 30px; flex: none; }
.move-san { width: 76px; }
.move-list .move-san:last-child { width: auto; }
.hints { font-size: 12px; color: var(--text-dim); line-height: 1.5; }
.hints p { margin: 6px 0; }
.hints code {
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px;
background: rgba(0,0,0,0.35); padding: 1px 4px; border-radius: 3px; color: var(--accent);
}
@media (max-width: 1020px) {
#layout { justify-content: center; }
#panel { width: var(--board-size); max-width: 640px; }
}
@media (max-width: 560px) {
:root { --board-size: min(94vw, 520px); }
#board { border-width: 6px; }
}
/* ── css: board ── */
/* ============ board cells, pieces, highlights ============ */
.board-wrap { position: relative; width: 100%; height: 100%; }
.board { display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); width: 100%; height: 100%; }
.sq { position: relative; overflow: hidden; }
.sq.light { background: var(--light); }
.sq.dark { background: var(--dark); }
.glyph {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: calc(var(--board-size) * 0.105);
line-height: 1;
pointer-events: none;
}
.glyph.wp {
color: #f7f2e7;
text-shadow: 0 1px 2px rgba(0,0,0,0.55), 0 0 2px rgba(0,0,0,0.6);
}
.glyph.bp {
color: #2b241d;
text-shadow: 0 1px 2px rgba(255,255,255,0.12);
}
/* selection / last move / check tints */
.sq.sel.light, .sq.sel.dark { box-shadow: inset 0 0 0 3px #3d9970; }
.sq.last { background-image: linear-gradient(var(--hint-last, rgba(255,213,0,0.42)), var(--hint-last, rgba(255,213,0,0.42))); }
.sq.check {
background-image: radial-gradient(circle at 50% 45%, rgba(255,60,40,0.85) 18%, rgba(255,60,40,0.30) 42%, transparent 68%);
}
.sq.hover { box-shadow: inset 0 0 0 2px rgba(255,255,255,0.35); }
/* move hints: dots for quiet moves, rings for captures */
.hint { position: absolute; inset: 0; pointer-events: none; }
.hint.dot::after {
content: ""; position: absolute; left: 50%; top: 50%;
width: 30%; height: 30%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgba(25, 35, 20, 0.42);
box-shadow: 0 0 6px rgba(0,0,0,0.3);
}
.hint.ring::after {
content: ""; position: absolute; inset: 4%;
border-radius: 50%;
border: calc(var(--board-size) * 0.008) solid rgba(150, 30, 20, 0.55);
}
.sq.light .hint.dot::after { background: rgba(25, 35, 20, 0.40); }
.sq.dark .hint.dot::after { background: rgba(240, 225, 190, 0.45); }
.sq.light .hint.ring::after { border-color: rgba(150, 30, 20, 0.6); }
.sq.dark .hint.ring::after { border-color: rgba(120, 20, 10, 0.65); }
/* coordinate labels */
.coord {
position: absolute; font-size: calc(var(--board-size) * 0.026);
font-family: Georgia, serif; letter-spacing: 0.02em; pointer-events: none;
color: rgba(0,0,0,0.55);
}
.sq.light .coord { color: rgba(70, 45, 20, 0.8); }
.coord.rank { left: 3%; top: 2%; }
.coord.file { right: 4%; bottom: 1.5%; }
/* drag ghost */
.drag-ghost {
position: absolute; z-index: 20;
width: calc(var(--board-size) * 0.125);
height: calc(var(--board-size) * 0.125);
display: flex; align-items: center; justify-content: center;
font-size: calc(var(--board-size) * 0.1);
pointer-events: none;
opacity: 0.9;
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.5));
}
/* captured tray glyphs */
.tray .cap-glyph { display: inline-block; }
.tray .cap-glyph.wp { color: #e8e0cf; }
.tray .cap-glyph.bp { color: #6b5c49; }
/* ── css: overlays ── */
/* ============ promotion picker, banner ============ */
.promo-picker {
display: none;
position: absolute; z-index: 25;
grid-auto-rows: 1fr;
gap: 3px;
background: #241f1a;
border: 1px solid var(--accent);
border-radius: 8px;
padding: 6px;
box-shadow: 0 12px 30px rgba(0,0,0,0.6);
}
.promo-picker.pw .promo-btn { color: #f7f2e7; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
.promo-picker.pb .promo-btn { color: #2b241d; }
.promo-btn {
font-size: calc(var(--board-size) * 0.075);
background: transparent; border: 1px solid var(--line);
border-radius: 6px; cursor: pointer;
padding: 4px 6px; line-height: 1;
transition: background 0.1s;
}
.promo-btn:hover { background: rgba(216, 176, 106, 0.25); border-color: var(--accent); }
.banner {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(0.9);
width: min(80%, 420px);
padding: 22px 26px;
text-align: center;
font-size: 22px; letter-spacing: 0.04em;
background: rgba(18, 14, 10, 0.94);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 16px 44px rgba(0,0,0,0.6);
opacity: 0; pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 24;
}
.banner.show { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); }
.banner.win { border-color: var(--accent); color: var(--accent); }
.banner.draw { border-color: #8a7a63; color: #d8cbb4; }
</style>
</head>
<body>
<div id="app"><header id="topbar">
<div id="logo">♞ <span>Wood & Slate Chess</span></div>
<div id="controls">
<label class="ctl"><span>Play as</span>
<select id="sel-color">
<option value="w">White</option>
<option value="b">Black</option>
<option value="none">Both (analysis)</option>
</select>
</label>
<label class="ctl"><span>Depth</span>
<select id="sel-diff">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</label>
<button id="btn-flip" title="Flip board">⇅</button>
<button id="btn-undo" title="Undo (takes back your move and the AI reply)">↶ Undo</button>
<button id="btn-new" title="Start a new game">✦ New</button>
<button id="btn-mute" title="Toggle sound">♫ sound</button>
</div>
</header>
<main id="layout">
<section id="board-zone">
<div id="board"></div>
<div id="banner" class="banner" aria-live="polite"></div>
</section>
<aside id="panel">
<div id="turn-label" class="turn"></div>
<div class="panel-block trays">
<div class="tray-row"><span class="tray-name">White captured</span><div id="tray-white" class="tray"></div></div>
<div class="tray-row"><span class="tray-name">Black captured</span><div id="tray-black" class="tray"></div></div>
</div>
<div class="panel-block moves-block">
<div class="panel-title">Moves</div>
<div id="move-list" class="move-list"></div>
</div>
<div class="panel-block hints">
<div class="panel-title">How to play</div>
<p>Click a piece, then a highlighted square — or drag it. Dots mark quiet moves; rings mark captures. The red-glowed square is the king in check.</p>
<p>Debug: <code>game.fen()</code>, <code>game.legalMoves()</code>, <code>game.move('e2e4')</code>, <code>game.setup(fen)</code>.</p>
</div>
</aside>
</main></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./app":"src/app.ts"},"src/state.ts":{"./constants":"src/constants.ts"},"src/rules.ts":{"./constants":"src/constants.ts","./state":"src/state.ts"},"src/movegen.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./rules":"src/rules.ts"},"src/fen.ts":{"./constants":"src/constants.ts","./state":"src/state.ts"},"src/san.ts":{"./state":"src/state.ts","./movegen":"src/movegen.ts","./rules":"src/rules.ts"},"src/draws.ts":{"./state":"src/state.ts","./constants":"src/constants.ts","./movegen":"src/movegen.ts","./rules":"src/rules.ts"},"src/ai.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./movegen":"src/movegen.ts","./rules":"src/rules.ts"},"src/persistence.ts":{"./state":"src/state.ts","./fen":"src/fen.ts"},"src/ui.ts":{"./constants":"src/constants.ts","./state":"src/state.ts"},"src/app.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./rules":"src/rules.ts","./movegen":"src/movegen.ts","./san":"src/san.ts","./draws":"src/draws.ts","./ai":"src/ai.ts","./sound":"src/sound.ts","./persistence":"src/persistence.ts","./ui":"src/ui.ts","./fen":"src/fen.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// ENTRY — build the App from persisted state (or fresh), attach UI, expose
// the headless debug API on window.game.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const app_1 = require("./app");
function start() {
const app = app_1.App.restore() || new app_1.App();
app.attach();
app.paint();
// Headless probe interface: game.fen(), game.move('e2e4'), game.legalMoves() ...
window.game = app.debugApi();
window.app = app; // full instance for deeper debugging
}
start();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// TUNABLE CONSTANTS — edit this block to re-theme or re-tune the engine
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.VAL = exports.PST = exports.FILES = exports.BLACK = exports.WHITE = exports.CONFIG = void 0;
exports.CONFIG = {
// Board palette (light / dark squares), classic warm wood
COLOR_LIGHT: "#f0d9b5",
COLOR_DARK: "#b58863",
// Highlight colors
COLOR_LAST_MOVE: "rgba(255, 213, 0, 0.42)",
COLOR_CHECK: "rgba(255, 46, 46, 0.75)",
COLOR_SELECT: "rgba(30, 120, 60, 0.55)",
COLOR_HINT_MOVE: "rgba(20, 40, 20, 0.35)", // dot for quiet moves
COLOR_HINT_CAPTURE: "rgba(120, 20, 20, 0.45)", // ring for captures
// Piece rendering
GLYPH_SCALE: 0.74, // glyph size relative to square
// AI
AI_TIME_SLICE_MS: 12, // max ms per synchronous search chunk
AI_BUDGET_MS: [250, 400, 700], // hard per-move time budget per difficulty (1,2,3)
DEPTHS: [1, 2, 3], // difficulty selector
// Evaluation
MATE_SCORE: 100000,
QUIESCE_LIMIT: 6, // ply of captures searched in quiescence
PVT_WINDOW_HALF: 55, // principal variation window half-width
};
exports.WHITE = 'w';
exports.BLACK = 'b';
exports.FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
// Piece square tables, indexed by square 0..63 (a1..h1 ... a8..h8),
// from the perspective of the piece's OWN color (row 0 = own back rank).
exports.PST = {
P: [
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5, -10, 0, 0, -10, -5, 5,
5, 10, 10, -20, -20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0,
],
N: [
-50, -40, -30, -30, -30, -30, -40, -50,
-40, -20, 0, 0, 0, 0, -20, -40,
-30, 0, 10, 15, 15, 10, 0, -30,
-30, 5, 15, 20, 20, 15, 5, -30,
-30, 0, 15, 20, 20, 15, 0, -30,
-30, 5, 10, 15, 15, 10, 5, -30,
-40, -20, 0, 5, 5, 0, -20, -40,
-50, -40, -30, -30, -30, -30, -40, -50,
],
B: [
-20, -10, -10, -10, -10, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 10, 10, 5, 0, -10,
-10, 5, 5, 10, 10, 5, 5, -10,
-10, 0, 10, 10, 10, 10, 0, -10,
-10, 10, 10, 10, 10, 10, 10, -10,
-10, 5, 0, 0, 0, 0, 5, -10,
-20, -10, -10, -10, -10, -10, -10, -20,
],
R: [
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0,
],
Q: [
-20, -10, -10, -5, -5, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 5, 5, 5, 0, -10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0, -10,
-10, 0, 5, 0, 0, 0, 0, -10,
-20, -10, -10, -5, -5, -10, -10, -20,
],
K: [
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-20, -30, -30, -40, -40, -30, -30, -20,
-10, -20, -20, -20, -20, -20, -20, -10,
20, 20, 0, 0, 0, 0, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20,
],
};
// Base material values (centipawns)
exports.VAL = { P: 100, N: 320, B: 330, R: 500, Q: 900, K: 0 };
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// BOARD REPRESENTATION
// 64-square "mailbox" style array: index 0..63, a1=0 ... h8=63.
// Board squares hold a piece string ("wP","bK") or null.
// State holds turn, castling rights, en-passant target, clocks and history.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.otherColor = exports.pieceType = exports.pieceColor = void 0;
exports.emptyBoard = emptyBoard;
exports.initialState = initialState;
exports.stateToFen = stateToFen;
exports.positionKey = positionKey;
exports.sqToAlgebraic = sqToAlgebraic;
exports.algebraicToSq = algebraicToSq;
exports.cloneState = cloneState;
exports.makeMoveOnState = makeMoveOnState;
const pieceColor = (p) => p[0];
exports.pieceColor = pieceColor;
const pieceType = (p) => p[1];
exports.pieceType = pieceType;
const otherColor = (c) => (c === 'w' ? 'b' : 'w');
exports.otherColor = otherColor;
function emptyBoard() {
return new Array(64).fill(null);
}
// Standard initial position (FEN: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR)
function initialState() {
const board = emptyBoard();
const back = ['R', 'N', 'B', 'Q', 'K', 'B', 'N', 'R'];
for (let f = 0; f < 8; f++) {
board[f] = 'w' + back[f];
board[8 + f] = 'wP';
board[48 + f] = 'bP';
board[56 + f] = 'b' + back[f];
}
return {
board, turn: 'w',
castling: { wk: true, wq: true, bk: true, bq: true },
ep: null, halfmove: 0, fullmove: 1,
history: [], positionCounts: new Map(),
};
}
// --- FEN helpers ----------------------------------------------------------
const FEN_CODE = { P: 'P', N: 'N', B: 'B', R: 'R', Q: 'Q', K: 'K' };
const FEN_INV = { P: 'P', N: 'N', B: 'B', R: 'R', Q: 'Q', K: 'K' };
function stateToFen(s) {
const rows = [];
for (let r = 7; r >= 0; r--) {
let row = '', empty = 0;
for (let f = 0; f < 8; f++) {
const p = s.board[r * 8 + f];
if (p === null) {
empty++;
continue;
}
if (empty) {
row += String(empty);
empty = 0;
}
row += (p[0] === 'w' ? FEN_CODE[p[1]] : FEN_CODE[p[1]].toLowerCase());
}
if (empty)
row += String(empty);
rows.push(row);
}
let c = '';
if (s.castling.wk)
c += 'K';
if (s.castling.wq)
c += 'Q';
if (s.castling.bk)
c += 'k';
if (s.castling.bq)
c += 'q';
if (!c)
c = '-';
const ep = s.ep === null ? '-' : sqToAlgebraic(s.ep);
return `${rows.join('/')} ${s.turn} ${c} ${ep} ${s.halfmove} ${s.fullmove}`;
}
// Compact position key (no castling/clocks) — used for 3-fold repetition
function positionKey(s) {
let key = '';
for (let i = 0; i < 64; i++) {
const p = s.board[i];
key += p === null ? '.' : p;
}
return key + s.turn;
}
function sqToAlgebraic(sq) {
const f = sq % 8, r = (sq >> 3) + 1;
return 'abcdefgh'[f] + String(r);
}
function algebraicToSq(s) {
if (s.length !== 2)
return null;
const f = 'abcdefgh'.indexOf(s[0]);
const r = parseInt(s[1], 10);
if (f < 0 || r < 1 || r > 8)
return null;
return ((r - 1) << 3) | f;
}
function cloneState(s) {
return {
board: s.board.slice(),
turn: s.turn,
castling: { ...s.castling },
ep: s.ep, halfmove: s.halfmove, fullmove: s.fullmove,
};
}
function makeMoveOnState(s, m) {
// Returns a NEW state with the move applied (used by the engine; does not
// touch history). Assumes m is legal for s.
const board = s.board.slice();
const piece = board[m.from];
let flags = m.flags;
board[m.from] = null;
let nextEp = null;
let halfmove = s.halfmove + 1;
if (piece[1] === 'P') {
const toFile = m.to % 8, fromFile = m.from % 8;
if (flags === 'ep') {
// captured pawn sits on m.to's file, m.from's rank (one rank behind m.to)
board[m.to + (s.turn === 'w' ? -8 : 8)] = null;
}
else if (m.captured) {
halfmove = 0;
}
if (piece[1] === 'P' && ((s.turn === 'w' && (m.to >> 3) === 7) || (s.turn === 'b' && (m.to >> 3) === 0)) && flags.startsWith('promo:')) {
board[m.to] = s.turn + flags.slice(6);
}
else {
board[m.to] = piece;
}
// doubled pawn -> ep target for opponent
if (Math.abs(m.to - m.from) === 16)
nextEp = (m.from + m.to) >> 1;
halfmove = 0; // pawn move resets 50-move clock (covers normal and double)
}
else {
if (m.captured)
halfmove = 0;
board[m.to] = piece;
if (flags === 'castle') {
// move the rook alongside
const rank = m.from >> 3;
const base = rank * 8;
if ((m.to % 8) === 6) { // kingside
board[base + 5] = board[base + 7];
board[base + 7] = null;
}
else { // queenside
board[base + 3] = board[base + 0];
board[base + 0] = null;
}
}
}
const turn = s.turn === 'w' ? 'b' : 'w';
const castling = { ...s.castling };
// King move kills both rights for that side; rook move/capture kills that side
if (piece[1] === 'K') {
if (s.turn === 'w') {
castling.wk = false;
castling.wq = false;
}
else {
castling.bk = false;
castling.bq = false;
}
}
const touch = (sq) => {
if (sq === 0)
castling.wq = false;
else if (sq === 7)
castling.wk = false;
else if (sq === 56)
castling.bq = false;
else if (sq === 63)
castling.bk = false;
};
touch(m.from);
touch(m.to);
// (rook captured on its corner: m.to already covers it; rook from corner: m.from covers it)
return {
board, turn, castling, ep: nextEp, halfmove,
fullmove: s.fullmove + (s.turn === 'b' ? 1 : 0),
};
}
};
// ── module: src/rules.ts ──
__mods["src/rules.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// ATTACK / CHECK RULES
// Direction deltas are (dRank, dFile) component pairs so file/rank bounds can
// be validated explicitly — no 64-index wraparound artifacts.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.ROOK_DIRS = exports.BISHOP_DIRS = exports.KING_DIRS = exports.KNIGHT_DIRS = void 0;
exports.isSquareAttacked = isSquareAttacked;
exports.findKing = findKing;
exports.inCheck = inCheck;
exports.KNIGHT_DIRS = [[1, 2], [1, -2], [2, 1], [2, -1], [-1, 2], [-1, -2], [-2, 1], [-2, -1]];
exports.KING_DIRS = [[0, 1], [0, -1], [1, 0], [1, 1], [1, -1], [-1, 0], [-1, 1], [-1, -1]];
exports.BISHOP_DIRS = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
exports.ROOK_DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const inRange = (r, f) => r >= 0 && r < 8 && f >= 0 && f < 8;
// Is `sq` attacked by any piece of color `by`?
function isSquareAttacked(board, sq, by) {
const f = sq % 8;
const r = sq >> 3;
// Pawns: a white pawn attacks from the rank below; black from the rank above
const pRank = by === 'w' ? r - 1 : r + 1;
if (pRank >= 0 && pRank < 8) {
for (const df of [-1, 1]) {
const cf = f + df;
if (cf >= 0 && cf < 8 && board[pRank * 8 + cf] === by + 'P')
return true;
}
}
// Knights
for (const [dr, df] of exports.KNIGHT_DIRS) {
const rr = r + dr, cf = f + df;
if (inRange(rr, cf) && board[rr * 8 + cf] === by + 'N')
return true;
}
// Kings
for (const [dr, df] of exports.KING_DIRS) {
const rr = r + dr, cf = f + df;
if (inRange(rr, cf) && board[rr * 8 + cf] === by + 'K')
return true;
}
// Sliding: bishops/queens
for (const [dr, df] of exports.BISHOP_DIRS) {
let rr = r + dr, cf = f + df;
while (inRange(rr, cf)) {
const p = board[rr * 8 + cf];
if (p) {
if (p === by + 'B' || p === by + 'Q')
return true;
break;
}
rr += dr;
cf += df;
}
}
// Sliding: rooks/queens
for (const [dr, df] of exports.ROOK_DIRS) {
let rr = r + dr, cf = f + df;
while (inRange(rr, cf)) {
const p = board[rr * 8 + cf];
if (p) {
if (p === by + 'R' || p === by + 'Q')
return true;
break;
}
rr += dr;
cf += df;
}
}
return false;
}
function findKing(board, color) {
const target = color + 'K';
for (let i = 0; i < 64; i++)
if (board[i] === target)
return i;
return -1;
}
function inCheck(board, color) {
const k = findKing(board, color);
return isSquareAttacked(board, k, color === 'w' ? 'b' : 'w');
}
};
// ── module: src/movegen.ts ──
__mods["src/movegen.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// LEGAL MOVE GENERATION
// Generator-based: pseudo-legal moves are produced, then every candidate is
// applied to a scratch board and the king's safety re-checked. Anything that
// leaves the mover's king in check (including castling through/onto check) is
// dropped, so the UI can only ever be offered legal moves.
// Directions are (dRank, dFile) component pairs — bounds validated explicitly.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.legalMoves = legalMoves;
exports.legalMovesFrom = legalMovesFrom;
exports.pseudoMoves = pseudoMoves;
exports.colorInCheck = colorInCheck;
const state_1 = require("./state");
const rules_1 = require("./rules");
const file = (sq) => sq % 8;
const rank = (sq) => sq >> 3;
const ok = (r, f) => r >= 0 && r < 8 && f >= 0 && f < 8;
// --- pseudo-legal generator ------------------------------------------------
function pseudoMoves(s, out) {
const board = s.board;
const me = s.turn;
const opp = (0, state_1.otherColor)(me);
for (let from = 0; from < 64; from++) {
const piece = board[from];
if (!piece || piece[0] !== me)
continue;
const t = piece[1];
const f = file(from), r = rank(from);
const push = (to, flags = '') => {
out.push({ from, to, piece, captured: flags === 'ep' ? (opp + 'P') : board[to], flags });
};
if (t === 'P') {
const dir = me === 'w' ? 1 : -1;
const startRank = me === 'w' ? 1 : 6;
const promoRank = me === 'w' ? 7 : 0;
// one step forward
const rr = r + dir;
if (ok(rr, f) && board[rr * 8 + f] === null) {
const one = rr * 8 + f;
if (r === startRank && ok(r + dir * 2, f) && board[(r + dir * 2) * 8 + f] === null) {
push((r + dir * 2) * 8 + f, 'double');
}
if (rr === promoRank) {
for (const p of ['Q', 'R', 'B', 'N'])
push(one, 'promo:' + p);
}
else
push(one);
}
// diagonal captures / en passant
for (const df of [-1, 1]) {
const cf = f + df;
if (!ok(rr, cf))
continue;
const to = rr * 8 + cf;
const target = board[to];
if (target && target[0] === opp) {
if (rr === promoRank) {
for (const p of ['Q', 'R', 'B', 'N'])
push(to, 'promo:' + p);
}
else
push(to);
}
else if (target === null && to === s.ep) {
push(to, 'ep');
}
}
continue;
}
if (t === 'N') {
for (const [dr, df] of rules_1.KNIGHT_DIRS) {
const rr = r + dr, cf = f + df;
if (!ok(rr, cf))
continue;
const to = rr * 8 + cf;
const target = board[to];
if (target === null || target[0] === opp)
push(to);
}
continue;
}
if (t === 'K') {
for (const [dr, df] of rules_1.KING_DIRS) {
const rr = r + dr, cf = f + df;
if (!ok(rr, cf))
continue;
const to = rr * 8 + cf;
const target = board[to];
if (target === null || target[0] === opp)
push(to);
}
// Castling: rights + empty squares only here; check-safety verified in isLegal
const base = r * 8;
if (f === 4) {
if (me === 'w' && s.castling.wk && board[base + 5] === null && board[base + 6] === null
&& board[base + 7] === 'wR' && !(0, rules_1.inCheck)(board, me))
push(base + 6, 'castle');
if (me === 'b' && s.castling.bk && board[base + 5] === null && board[base + 6] === null
&& board[base + 7] === 'bR' && !(0, rules_1.inCheck)(board, me))
push(base + 6, 'castle');
if (me === 'w' && s.castling.wq && board[base + 1] === null && board[base + 2] === null && board[base + 3] === null
&& board[base + 0] === 'wR' && !(0, rules_1.inCheck)(board, me))
push(base + 2, 'castle');
if (me === 'b' && s.castling.bq && board[base + 1] === null && board[base + 2] === null && board[base + 3] === null
&& board[base + 0] === 'bR' && !(0, rules_1.inCheck)(board, me))
push(base + 2, 'castle');
}
continue;
}
// Sliding pieces
const dirs = t === 'B' ? rules_1.BISHOP_DIRS : t === 'R' ? rules_1.ROOK_DIRS : [...rules_1.BISHOP_DIRS, ...rules_1.ROOK_DIRS];
for (const [dr, df] of dirs) {
let rr = r + dr, cf = f + df;
while (ok(rr, cf)) {
const to = rr * 8 + cf;
const target = board[to];
if (target === null)
push(to);
else {
if (target[0] === opp)
push(to);
break;
}
rr += dr;
cf += df;
}
}
}
}
// --- legality filter -------------------------------------------------------
function isLegal(s, m) {
const board = s.board.slice();
const piece = board[m.from];
board[m.from] = null;
if (m.flags === 'ep') {
board[m.to + (s.turn === 'w' ? -8 : 8)] = null;
}
if (m.flags.startsWith('promo:')) {
board[m.to] = s.turn + m.flags.slice(6);
}
else {
board[m.to] = piece;
}
if (m.flags === 'castle') {
const base = rank(m.from) * 8;
if (file(m.to) === 6) {
board[base + 5] = board[base + 7];
board[base + 7] = null;
}
else {
board[base + 3] = board[base + 0];
board[base + 0] = null;
}
}
const me = s.turn;
const opp = (0, state_1.otherColor)(me);
if (piece[1] === 'K') {
// king moved: destination must be safe; castling additionally requires the
// square the king passes over to be safe
if ((0, rules_1.isSquareAttacked)(board, m.to, opp))
return false;
if (m.flags === 'castle') {
const mid = file(m.to) === 6 ? rank(m.from) * 8 + 5 : rank(m.from) * 8 + 3;
if ((0, rules_1.isSquareAttacked)(board, mid, opp))
return false;
}
return true;
}
// any other piece: the king (wherever it stands) must not be left in check
const k = (0, rules_1.findKing)(board, me);
return !(0, rules_1.isSquareAttacked)(board, k, opp);
}
// Public API: all legal moves for the side to move.
function legalMoves(s) {
const pseudo = [];
pseudoMoves(s, pseudo);
const legal = [];
for (const m of pseudo)
if (isLegal(s, m))
legal.push(m);
return legal;
}
// Legal moves for ONE piece (drives selection highlights in the UI).
function legalMovesFrom(s, from) {
const all = legalMoves(s);
return all.filter(m => m.from === from);
}
// Convenience: is side `color` currently in check in state s?
function colorInCheck(s, color) {
return (0, rules_1.inCheck)(s.board, color);
}
};
// ── module: src/fen.ts ──
__mods["src/fen.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// FEN PARSING (for debug setup / position loading)
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.fenToState = fenToState;
exports.fenToFullState = fenToFullState;
const state_1 = require("./state");
const CODE_INV = {
p: 'P', n: 'N', b: 'B', r: 'R', q: 'Q', k: 'K',
P: 'P', N: 'N', B: 'B', R: 'R', Q: 'Q', K: 'K',
};
function fenToState(fen) {
const parts = fen.trim().split(/\s+/);
const board = new Array(64).fill(null);
const rows = parts[0].split('/');
if (rows.length !== 8)
throw new Error('FEN: expected 8 ranks');
for (let i = 0; i < 8; i++) {
const row = rows[i];
let f = 0;
for (const ch of row) {
if (ch >= '1' && ch <= '8') {
f += parseInt(ch, 10);
continue;
}
if (f > 7)
throw new Error('FEN: rank overflow');
board[(7 - i) * 8 + f] = (ch === ch.toLowerCase() ? 'b' : 'w') + CODE_INV[ch];
f++;
}
if (f !== 8)
throw new Error('FEN: rank ' + (8 - i) + ' has ' + f + ' squares');
}
const turn = parts[1] === 'w' ? 'w' : 'b';
const c = parts[2] || '-';
const castling = {
wk: c.includes('K'), wq: c.includes('Q'),
bk: c.includes('k'), bq: c.includes('q'),
};
let ep = null;
if (parts[3] && parts[3] !== '-') {
const f = 'abcdefgh'.indexOf(parts[3][0]);
const r = parseInt(parts[3][1], 10);
if (f >= 0 && r >= 1 && r <= 8)
ep = (r - 1) * 8 + f;
}
return {
board, turn, castling, ep,
halfmove: parts[4] ? parseInt(parts[4], 10) || 0 : 0,
fullmove: parts[5] ? parseInt(parts[5], 10) || 1 : 1,
};
}
function fenToFullState(fen) {
const s = fenToState(fen);
return { ...s, history: [], positionCounts: new Map([[(0, state_1.positionKey)(s), 1]]) };
}
};
// ── module: src/san.ts ──
__mods["src/san.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// STANDARD ALGEBRAIC NOTATION
// Nf3, exd6 e.p., O-O, e8=Q+, Qxd8#, ...
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.moveToSan = moveToSan;
const state_1 = require("./state");
const movegen_1 = require("./movegen");
const rules_1 = require("./rules");
// Disambiguation: file if unique among same-piece movers to `to`,
// else rank if unique, else both.
function moveToSan(s, m) {
const board = s.board;
let san;
const piece = m.piece[1];
const fromA = (0, state_1.sqToAlgebraic)(m.from);
const toA = (0, state_1.sqToAlgebraic)(m.to);
if (m.flags === 'castle') {
san = (m.to % 8) === 6 ? 'O-O' : 'O-O-O';
}
else if (piece === 'P') {
const cap = (m.captured || m.flags === 'ep') ? 'abcdefgh'[m.from % 8] + 'x' : '';
san = cap + toA;
if (m.flags.startsWith('promo:'))
san += '=' + m.flags.slice(6);
if (m.flags === 'ep')
san += ' e.p.';
}
else {
let disambig = '';
const legal = (0, movegen_1.legalMoves)(s);
const rivals = [];
for (let sq = 0; sq < 64; sq++) {
if (sq === m.from || board[sq] !== m.piece)
continue;
const can = legal.some(l => l.from === sq && l.to === m.to);
if (can)
rivals.push(sq);
}
if (rivals.length > 0) {
const sameFile = rivals.some(r => (r % 8) === (m.from % 8));
const sameRank = rivals.some(r => (r >> 3) === (m.from >> 3));
if (!sameFile)
disambig = 'abcdefgh'[m.from % 8];
else if (!sameRank)
disambig = String((m.from >> 3) + 1);
else
disambig = fromA;
}
san = piece + disambig + (m.captured ? 'x' : '') + toA;
}
// Check / mate suffix
const after = (0, state_1.makeMoveOnState)(s, m);
if ((0, rules_1.inCheck)(after.board, after.turn)) {
const replies = (0, movegen_1.legalMoves)(after);
san += replies.length === 0 ? '#' : '+';
}
return san;
}
};
// ── module: src/draws.ts ──
__mods["src/draws.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// DRAW / GAME-STATUS DETECTION
// checkmate, stalemate, insufficient material (K vs K, K+B/N vs K, K+B vs K+B
// same-colored bishops), 50-move rule, threefold repetition.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.insufficientMaterial = insufficientMaterial;
exports.statusOf = statusOf;
exports.isRepetition = isRepetition;
exports.fullStatus = fullStatus;
exports.kingInCheckForBanner = kingInCheckForBanner;
const state_1 = require("./state");
const movegen_1 = require("./movegen");
const rules_1 = require("./rules");
// Insufficient material: K vs K; K+minor vs K; K+B vs K+B where both bishops
// sit on squares of the same color.
function insufficientMaterial(board) {
const items = [];
for (let sq = 0; sq < 64; sq++) {
const p = board[sq];
if (!p)
continue;
const t = (0, state_1.pieceType)(p);
if (t === 'K')
continue;
items.push({ t, color: p[0], sq });
}
if (items.length === 0)
return true; // bare kings
if (items.length === 1) {
const t = items[0].t;
return t === 'B' || t === 'N';
}
// K+B vs K+B, same colored bishops
if (items.length === 2 && items[0].t === 'B' && items[1].t === 'B'
&& items[0].color !== items[1].color) {
const sqColor = (sq) => ((sq % 8) + (sq >> 3)) % 2;
if (sqColor(items[0].sq) === sqColor(items[1].sq))
return true;
}
return false;
}
function statusOf(s) {
const legal = (0, movegen_1.legalMoves)(s);
const checked = (0, rules_1.inCheck)(s.board, s.turn);
if (legal.length === 0) {
if (checked)
return { result: 'checkmate', winner: s.turn === 'w' ? 'b' : 'w' };
return { result: 'stalemate', winner: null };
}
if (insufficientMaterial(s.board))
return { result: 'insufficient', winner: null };
if (s.halfmove >= 100)
return { result: 'fifty', winner: null };
return { result: 'none', winner: null };
}
function isRepetition(s) {
const key = (0, state_1.positionKey)(s);
return (s.positionCounts.get(key) || 0) >= 3;
}
// Full status including threefold (requires history state)
function fullStatus(s) {
const st = statusOf(s);
if (st.result !== 'none')
return st;
if (isRepetition(s))
return { result: 'threefold', winner: null };
return st;
}
function kingInCheckForBanner(s) {
if (!(0, rules_1.inCheck)(s.board, s.turn))
return null;
return (0, rules_1.findKing)(s.board, s.turn);
}
};
// ── module: src/ai.ts ──
__mods["src/ai.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// AI — negamax with alpha-beta, MVV-LVA move ordering, shallow quiescence,
// principal-variation search (PVS) and O(1) make/unmake on a mutable search
// context (king positions tracked incrementally). The root loop runs in
// time slices with a hard budget so the UI never freezes and depth 3 stays
// well under a second.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.TimeSliceError = void 0;
exports.evaluate = evaluate;
exports.prepareRoot = prepareRoot;
exports.stepRoot = stepRoot;
exports.findBestMove = findBestMove;
const constants_1 = require("./constants");
const state_1 = require("./state");
const movegen_1 = require("./movegen");
const rules_1 = require("./rules");
const MATE = constants_1.CONFIG.MATE_SCORE;
class TimeSliceError extends Error {
constructor() { super('timeslice'); this.name = 'TimeSliceError'; }
}
exports.TimeSliceError = TimeSliceError;
function initCtx(s) {
return {
board: s.board.slice(),
turn: s.turn,
castling: { ...s.castling },
ep: s.ep,
kingW: (0, rules_1.findKing)(s.board, 'w'),
kingB: (0, rules_1.findKing)(s.board, 'b'),
};
}
function makeMove(ctx, m) {
const board = ctx.board;
const piece = board[m.from];
const u = {
from: m.from, to: m.to, piece,
captured: null, capSq: m.to, flags: m.flags,
prevTurn: ctx.turn, prevEp: ctx.ep, prevCastling: ctx.castling,
rookFrom: -1, rookTo: -1,
kingW: ctx.kingW, kingB: ctx.kingB,
};
if (m.flags === 'ep') {
u.capSq = m.to + (ctx.turn === 'w' ? -8 : 8);
u.captured = board[u.capSq];
board[u.capSq] = null;
board[m.to] = piece;
}
else {
u.captured = board[m.to];
if (m.flags.startsWith('promo:'))
board[m.to] = ctx.turn + m.flags.slice(6);
else
board[m.to] = piece;
if (m.flags === 'castle') {
const base = (m.from >> 3) * 8;
if ((m.to % 8) === 6) {
u.rookFrom = base + 7;
u.rookTo = base + 5;
}
else {
u.rookFrom = base;
u.rookTo = base + 3;
}
board[u.rookTo] = board[u.rookFrom];
board[u.rookFrom] = null;
}
}
board[m.from] = null;
if (m.flags === 'castle' || piece[1] === 'K') {
if (piece[0] === 'w')
ctx.kingW = m.to;
else
ctx.kingB = m.to;
}
ctx.castling = { ...u.prevCastling };
if (piece[1] === 'K') {
if (ctx.turn === 'w') {
ctx.castling.wk = false;
ctx.castling.wq = false;
}
else {
ctx.castling.bk = false;
ctx.castling.bq = false;
}
}
if (m.from === 0)
ctx.castling.wq = false;
else if (m.from === 7)
ctx.castling.wk = false;
else if (m.from === 56)
ctx.castling.bq = false;
else if (m.from === 63)
ctx.castling.bk = false;
if (m.to === 0)
ctx.castling.wq = false;
else if (m.to === 7)
ctx.castling.wk = false;
else if (m.to === 56)
ctx.castling.bq = false;
else if (m.to === 63)
ctx.castling.bk = false;
ctx.ep = m.flags === 'double' ? (m.from + m.to) >> 1 : null;
ctx.turn = (0, state_1.otherColor)(ctx.turn);
return u;
}
function unmakeMove(ctx, u) {
const board = ctx.board;
board[u.from] = u.piece;
if (u.flags === 'ep') {
board[u.to] = null;
board[u.capSq] = u.captured;
}
else {
board[u.to] = u.captured;
}
if (u.flags === 'castle') {
board[u.rookFrom] = board[u.rookTo];
board[u.rookTo] = null;
}
ctx.turn = u.prevTurn;
ctx.ep = u.prevEp;
ctx.castling = u.prevCastling;
ctx.kingW = u.kingW;
ctx.kingB = u.kingB;
}
// --- evaluation (side to move's perspective) --------------------------------
function evaluate(s) {
let score = 0;
const board = s.board;
for (let sq = 0; sq < 64; sq++) {
const p = board[sq];
if (!p)
continue;
const t = p[1];
if (t === 'K')
continue;
const base = constants_1.VAL[t];
const pst = constants_1.PST[t];
if (p[0] === 'w')
score += base + pst[sq];
else
score -= base + pst[63 - sq];
}
return s.turn === 'w' ? score : -score;
}
function evalCtx(ctx) {
return evaluate(ctx);
}
// --- search -----------------------------------------------------------------
function search(ctx, depth, alpha, beta, ply, deadline, undos) {
if ((Date.now() | 0) >= deadline)
throw new TimeSliceError();
if (depth === 0)
return quiesce(ctx, alpha, beta, ply, deadline, undos);
const pseudo = [];
(0, movegen_1.pseudoMoves)(ctx, pseudo);
orderMoves(pseudo);
let anyLegal = false;
let best = -Infinity;
for (const m of pseudo) {
const u = makeMove(ctx, m);
undos.push(u);
const mover = u.prevTurn;
const kingSq = mover === 'w' ? ctx.kingW : ctx.kingB;
const opp = (0, state_1.otherColor)(mover);
let legal = !(0, rules_1.isSquareAttacked)(ctx.board, kingSq, opp);
if (legal && m.flags === 'castle') {
const mid = (m.to % 8) === 6 ? (m.from >> 3) * 8 + 5 : (m.from >> 3) * 8 + 3;
legal = !(0, rules_1.isSquareAttacked)(ctx.board, mid, opp);
}
if (!legal) {
unmakeMove(ctx, undos.pop());
continue;
}
anyLegal = true;
const score = -search(ctx, depth - 1, -beta, -alpha, ply + 1, deadline, undos);
unmakeMove(ctx, undos.pop());
if (score > best)
best = score;
if (score > alpha)
alpha = score;
if (alpha >= beta)
break;
}
if (!anyLegal) {
const inChk = (0, rules_1.isSquareAttacked)(ctx.board, ctx.turn === 'w' ? ctx.kingW : ctx.kingB, (0, state_1.otherColor)(ctx.turn));
return inChk ? -(MATE - ply) : 0;
}
return best;
}
// --- quiescence (captures + promotions only) --------------------------------
function quiesce(ctx, alpha, beta, ply, deadline, undos) {
if ((Date.now() | 0) >= deadline)
throw new TimeSliceError();
const standPat = evalCtx(ctx);
if (standPat >= beta)
return standPat;
if (standPat > alpha)
alpha = standPat;
if (ply >= constants_1.CONFIG.QUIESCE_LIMIT)
return alpha;
const pseudo = [];
(0, movegen_1.pseudoMoves)(ctx, pseudo);
const caps = [];
for (const m of pseudo)
if (m.captured || m.flags.startsWith('promo:'))
caps.push(m);
orderMoves(caps);
for (const m of caps) {
const u = makeMove(ctx, m);
undos.push(u);
// A king move is legal iff its destination (tracked in ctx) is not attacked;
// other pieces leave the king where it was, so the same test on the king
// square is invalid for them — but a capture by a non-king piece can never
// change the king's square, so checking the mover's king square (which for
// non-king moves is unchanged) is exactly right.
const mover = u.prevTurn;
const kingSq = mover === 'w' ? ctx.kingW : ctx.kingB;
const ok = mover === 'w' ? !(0, rules_1.isSquareAttacked)(ctx.board, kingSq, 'b') : !(0, rules_1.isSquareAttacked)(ctx.board, kingSq, 'w');
if (!ok) {
unmakeMove(ctx, undos.pop());
continue;
}
const score = -quiesce(ctx, -beta, -alpha, ply + 1, deadline, undos);
unmakeMove(ctx, undos.pop());
if (score > alpha)
alpha = score;
if (alpha >= beta)
break;
}
return alpha;
}
// --- move ordering: MVV-LVA + promotions first -------------------------------
const VAL_ORDER = { P: 1, N: 3, B: 3, R: 5, Q: 9, K: 0 };
function orderMoves(moves) {
for (const m of moves) {
let score = 0;
if (m.captured)
score += 10 * VAL_ORDER[m.captured[1]] - VAL_ORDER[m.piece[1]];
if (m.flags.startsWith('promo:'))
score += 90;
m.order = score;
}
moves.sort((a, b) => (b.order || 0) - (a.order || 0));
}
// Builds the ordered list of legal root moves for the side to move.
function prepareRoot(s, depth) {
const ctx = initCtx(s);
const pseudo = [];
(0, movegen_1.pseudoMoves)(ctx, pseudo);
const legalMoves = [];
const undos = [];
for (const m of pseudo) {
const u = makeMove(ctx, m);
undos.push(u);
const mover = u.prevTurn;
const kingSq = mover === 'w' ? ctx.kingW : ctx.kingB;
const opp = (0, state_1.otherColor)(mover);
let safe = !(0, rules_1.isSquareAttacked)(ctx.board, kingSq, opp);
if (safe && m.flags === 'castle') {
const mid = (m.to % 8) === 6 ? (m.from >> 3) * 8 + 5 : (m.from >> 3) * 8 + 3;
safe = !(0, rules_1.isSquareAttacked)(ctx.board, mid, opp);
}
unmakeMove(ctx, undos.pop());
if (safe)
legalMoves.push(m);
}
orderMoves(legalMoves);
return { ctx, moves: legalMoves, idx: 0, alpha: -Infinity, best: null, bestScore: -Infinity, depth, undos: [], first: true };
}
// Steps the root loop until a slice deadline or budget; returns null while
// more work is pending, or the final SearchResult when done/budget-exceeded.
function stepRoot(rs, sliceDeadline, budgetDeadline) {
try {
while (rs.idx < rs.moves.length) {
if ((Date.now() | 0) >= sliceDeadline)
return null; // resume next slice
if ((Date.now() | 0) >= budgetDeadline)
break; // out of budget: play best so far
const m = rs.moves[rs.idx++];
const u = makeMove(rs.ctx, m);
rs.undos.push(u);
const mover = u.prevTurn;
const kingSq = mover === 'w' ? rs.ctx.kingW : rs.ctx.kingB;
const opp = (0, state_1.otherColor)(mover);
let legal = !(0, rules_1.isSquareAttacked)(rs.ctx.board, kingSq, opp);
if (legal && m.flags === 'castle') {
const mid = (m.to % 8) === 6 ? (m.from >> 3) * 8 + 5 : (m.from >> 3) * 8 + 3;
legal = !(0, rules_1.isSquareAttacked)(rs.ctx.board, mid, opp);
}
if (!legal) {
unmakeMove(rs.ctx, rs.undos.pop());
rs.first = false;
continue;
}
let score;
if (rs.first) {
score = -search(rs.ctx, rs.depth - 1, -Infinity, -rs.alpha, 1, sliceDeadline, rs.undos);
rs.first = false;
}
else {
// PVS: null window first
score = -search(rs.ctx, rs.depth - 1, -rs.alpha - 1, -rs.alpha, 1, sliceDeadline, rs.undos);
if (score > rs.alpha && score < Infinity) {
score = -search(rs.ctx, rs.depth - 1, -Infinity, -rs.alpha, 1, sliceDeadline, rs.undos);
}
}
unmakeMove(rs.ctx, rs.undos.pop());
if (score > rs.bestScore) {
rs.bestScore = score;
rs.best = m;
}
if (score > rs.alpha)
rs.alpha = score;
}
}
catch (e) {
if (e instanceof TimeSliceError) {
// rewind any made-but-not-undone moves so ctx is consistent for next slice
while (rs.undos.length)
unmakeMove(rs.ctx, rs.undos.pop());
return null;
}
throw e;
}
const move = rs.best !== null ? rs.best : rs.moves[0]; // fallback: first (best-ordered) legal move
return { move, score: rs.bestScore, depth: rs.depth, nodes: 0 };
}
// One-shot convenience (used only for trivial cases); App uses the sliced pair.
function findBestMove(s, depth, budgetMs) {
const rs = prepareRoot(s, depth);
const deadline = performance.now() + budgetMs;
while (true) {
const res = stepRoot(rs, performance.now() + 20, deadline);
if (res)
return res;
}
}
};
// ── module: src/sound.ts ──
__mods["src/sound.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// SOUND — tiny Web Audio synths for move / capture / check / game-over.
// No assets; everything is oscillator + gain envelopes. Mute toggles.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.sound = void 0;
class Sound {
constructor() {
this.ctx = null;
this.muted = false;
}
ensure() {
try {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC)
return null;
this.ctx = new AC();
}
if (this.ctx.state === 'suspended')
void this.ctx.resume();
return this.ctx;
}
catch {
return null;
}
}
blip(freq, dur, gainV, type, when = 0, glideTo) {
const ctx = this.ensure();
if (!ctx || this.muted)
return;
const t0 = ctx.currentTime + when;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (glideTo)
osc.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gainV, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
play(name) {
if (this.muted)
return;
switch (name) {
case 'move':
this.blip(210, 0.09, 0.22, 'triangle', 0, 160);
break;
case 'capture':
this.blip(140, 0.12, 0.3, 'square', 0, 90);
this.blip(90, 0.16, 0.24, 'triangle', 0.02, 60);
break;
case 'check':
this.blip(660, 0.1, 0.18, 'sine');
this.blip(880, 0.16, 0.18, 'sine', 0.09);
break;
case 'mate':
this.blip(392, 0.16, 0.2, 'triangle');
this.blip(311, 0.16, 0.2, 'triangle', 0.15);
this.blip(233, 0.4, 0.22, 'triangle', 0.3);
break;
case 'start':
this.blip(523, 0.12, 0.16, 'sine', 0, 660);
break;
case 'undo':
this.blip(300, 0.1, 0.16, 'sine', 0, 220);
break;
}
}
toggle() {
this.muted = !this.muted;
return this.muted;
}
}
exports.sound = new Sound();
};
// ── module: src/persistence.ts ──
__mods["src/persistence.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// PERSISTENCE — serializes the full game (FEN, per-move FENs for exact undo,
// move list, position counts, settings) to localStorage; restores on reload.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultSettings = defaultSettings;
exports.save = save;
exports.load = load;
exports.clear = clear;
const state_1 = require("./state");
const fen_1 = require("./fen");
const KEY = 'woodSlateChess.v1';
function defaultSettings() {
return { aiColor: 'b', difficulty: 2, muted: false, flipped: false };
}
function save(fen, turn, history, positionCounts, settings, lastMove, gameOver) {
const data = { fen, turn, history, positionCounts, settings, lastMove, gameOver };
try {
localStorage.setItem(KEY, JSON.stringify(data));
}
catch { /* storage blocked */ }
}
function load() {
try {
const raw = localStorage.getItem(KEY);
if (!raw)
return null;
const data = JSON.parse(raw);
const state = (0, fen_1.fenToFullState)(data.fen);
const entries = (data.history || []).map(h => {
const prev = (0, fen_1.fenToState)(h.prevFen);
return {
move: { from: h.from, to: h.to, piece: h.piece, captured: h.captured, flags: h.flags },
prevBoard: prev.board,
prevCastling: prev.castling,
prevEp: prev.ep,
prevHalf: prev.halfmove,
prevFull: prev.fullmove,
prevPositionKey: (0, state_1.positionKey)(prev),
positionKey: h.posKey || '',
san: h.san,
};
});
state.positionCounts = new Map(data.positionCounts || []);
const settings = { ...defaultSettings(), ...(data.settings || {}) };
return { state, settings, lastMove: data.lastMove || null, entries };
}
catch {
return null;
}
}
function clear() {
try {
localStorage.removeItem(KEY);
}
catch { /* ignore */ }
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// UI — board rendering, click-to-move AND drag-and-drop input, promotion
// picker, highlights, move list, captured trays, controls. Pure DOM, no
// canvas; the engine never touches this file.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.HINT_SCALE = exports.GLYPHS = void 0;
exports.buildBoard = buildBoard;
exports.sqForCell = sqForCell;
exports.cellForSq = cellForSq;
exports.paintBoard = paintBoard;
exports.showPromoPicker = showPromoPicker;
exports.hidePromoPicker = hidePromoPicker;
exports.capturedTrays = capturedTrays;
exports.trayHTML = trayHTML;
exports.renderMoveList = renderMoveList;
exports.setTurnLabel = setTurnLabel;
exports.showBanner = showBanner;
exports.hideBanner = hideBanner;
const constants_1 = require("./constants");
exports.GLYPHS = {
K: '\u265A', Q: '\u265B', R: '\u265C', B: '\u265D', N: '\u265E', P: '\u265F',
};
function buildBoard() {
const wrapper = document.createElement('div');
wrapper.className = 'board-wrap';
const board = document.createElement('div');
board.className = 'board';
const cells = [];
for (let i = 0; i < 64; i++) {
const cell = document.createElement('div');
cell.className = 'sq';
cell.dataset.idx = String(i);
const glyph = document.createElement('span');
glyph.className = 'glyph';
const hint = document.createElement('span');
hint.className = 'hint';
cell.appendChild(glyph);
cell.appendChild(hint);
board.appendChild(cell);
cells.push(cell);
}
wrapper.appendChild(board);
// promotion picker overlay (hidden by default)
const promo = document.createElement('div');
promo.className = 'promo-picker';
promo.id = 'promo-picker';
wrapper.appendChild(promo);
return { boardEl: board, cells, wrapper };
}
// Display cell (row 0 = top) -> board square, honoring flip.
// Unflipped: top row = rank 8, left column = file a.
// Flipped: top row = rank 1, left column = file h.
function sqForCell(dr, dc, flipped) {
const rank = flipped ? dr : 7 - dr;
const file = flipped ? 7 - dc : dc;
return rank * 8 + file;
}
function cellForSq(sq, flipped) {
const rank = sq >> 3, file = sq % 8;
const dr = flipped ? rank : 7 - rank;
const dc = flipped ? 7 - file : file;
return { dr, dc };
}
// --- main board paint ------------------------------------------------------
function paintBoard(view, state, flipped, hl) {
const { cells } = view;
for (let dr = 0; dr < 8; dr++) {
for (let dc = 0; dc < 8; dc++) {
const sq = sqForCell(dr, dc, flipped);
const cell = cells[dr * 8 + dc];
const p = state.board[sq];
const glyph = cell.querySelector('.glyph');
const hint = cell.querySelector('.hint');
// classes
const light = ((sq >> 3) + (sq % 8)) % 2 === 1;
cell.className = 'sq ' + (light ? 'light' : 'dark')
+ (hl.selected === sq ? ' sel' : '')
+ (hl.lastMove && (hl.lastMove.from === sq || hl.lastMove.to === sq) ? ' last' : '')
+ (hl.checkSquare === sq ? ' check' : '')
+ (hl.hoverSquare === sq ? ' hover' : '');
// coord labels on outer edges
cell.textContent = '';
if (dc === 0) {
const l = document.createElement('span');
l.className = 'coord rank';
l.textContent = String((sq >> 3) + 1);
cell.appendChild(l);
}
if (dr === 7) {
const l = document.createElement('span');
l.className = 'coord file';
l.textContent = 'abcdefgh'[sq % 8];
cell.appendChild(l);
}
// piece
if (p) {
glyph.className = 'glyph ' + (p[0] === 'w' ? 'wp' : 'bp');
glyph.textContent = exports.GLYPHS[p[1]];
}
else {
glyph.className = 'glyph';
glyph.textContent = '';
}
// hints
const tgt = hl.targets.find(t => t.to === sq);
if (tgt) {
hint.className = 'hint ' + (tgt.captured || tgt.flags === 'ep' ? 'ring' : 'dot');
}
else {
hint.className = 'hint';
}
cell.appendChild(glyph);
cell.appendChild(hint);
}
}
}
// --- promotion picker ------------------------------------------------------
function showPromoPicker(view, state, toSq, flipped, onPick, onCancel) {
const picker = view.wrapper.querySelector('.promo-picker');
picker.innerHTML = '';
const { dr, dc } = cellForSq(toSq, flipped);
const cell = view.cells[dr * 8 + dc].getBoundingClientRect();
const wrap = view.wrapper.getBoundingClientRect();
const size = cell.width;
const color = state.turn;
picker.style.display = 'grid';
picker.style.gridTemplateColumns = 'repeat(2, auto)';
picker.style.left = (cell.left - wrap.left + size * 0.5 - size * 0.7) + 'px';
picker.style.top = (cell.top - wrap.top + size * 0.5 - size * 0.7) + 'px';
picker.style.width = size * 1.4 + 'px';
picker.classList.add(color === 'w' ? 'pw' : 'pb');
for (const pc of ['Q', 'R', 'B', 'N']) {
const b = document.createElement('button');
b.className = 'promo-btn';
b.textContent = exports.GLYPHS[pc];
b.addEventListener('pointerdown', e => { e.stopPropagation(); });
b.addEventListener('click', e => {
e.stopPropagation();
picker.style.display = 'none';
onPick(pc);
});
picker.appendChild(b);
}
picker.addEventListener('click', e => {
if (e.target === picker) {
picker.style.display = 'none';
onCancel();
}
});
}
function hidePromoPicker(view) {
const picker = view.wrapper.querySelector('.promo-picker');
picker.style.display = 'none';
picker.innerHTML = '';
}
// --- captured trays --------------------------------------------------------
const START_COUNTS = { P: 8, N: 2, B: 2, R: 2, Q: 1, K: 1 };
function capturedTrays(state) {
const onBoard = { wP: 0, wN: 0, wB: 0, wR: 0, wQ: 0, bP: 0, bN: 0, bB: 0, bR: 0, bQ: 0 };
for (let i = 0; i < 64; i++) {
const p = state.board[i];
if (p && p[1] !== 'K')
onBoard[p] = (onBoard[p] || 0) + 1;
}
const byWhite = [];
const byBlack = [];
let whitePoints = 0, blackPoints = 0;
const order = ['P', 'N', 'B', 'R', 'Q'];
const PTS = { P: 1, N: 3, B: 3, R: 5, Q: 9 };
for (const c of ['w', 'b']) {
for (const t of order) {
const missing = START_COUNTS[t] - (onBoard[c + t] || 0);
for (let i = 0; i < missing; i++) {
if (c === 'w')
byBlack.push({ piece: 'w' + t, type: t });
else
byWhite.push({ piece: 'b' + t, type: t });
}
}
}
for (const p of byWhite)
blackPoints += PTS[p.type]; // wait: byWhite = black pieces captured by white -> white gains
for (const p of byBlack)
whitePoints += PTS[p.type];
// advantage shown to the side AHEAD
return { byWhite, byBlack, advantage: whitePoints - blackPoints };
}
function trayHTML(list) {
return list.map(p => '<span class="cap-glyph ' + (p.piece[0] === 'w' ? 'wp' : 'bp') + '">' + exports.GLYPHS[p.type] + '</span>').join('');
}
// --- move list -------------------------------------------------------------
function renderMoveList(el, sans) {
el.innerHTML = '';
for (let i = 0; i < sans.length; i += 2) {
const row = document.createElement('div');
row.className = 'move-row';
const num = document.createElement('span');
num.className = 'move-num';
num.textContent = String(i / 2 + 1) + '.';
const w = document.createElement('span');
w.className = 'move-san';
w.textContent = sans[i];
row.appendChild(num);
row.appendChild(w);
if (sans[i + 1] !== undefined) {
const b = document.createElement('span');
b.className = 'move-san';
b.textContent = sans[i + 1];
row.appendChild(b);
}
el.appendChild(row);
}
el.scrollTop = el.scrollHeight;
}
// --- status / banner -------------------------------------------------------
function setTurnLabel(el, turn, checking, thinking, label) {
el.innerHTML = '';
const dot = document.createElement('span');
dot.className = 'turn-dot ' + (turn === 'w' ? 'w' : 'b');
el.appendChild(dot);
const txt = document.createElement('span');
txt.textContent = label + (checking ? ' — check!' : '') + (thinking ? ' · thinking…' : '');
el.appendChild(txt);
}
function showBanner(el, text, kind) {
el.textContent = text;
el.className = 'banner show ' + kind;
}
function hideBanner(el) {
el.className = 'banner';
el.textContent = '';
}
exports.HINT_SCALE = constants_1.CONFIG.GLYPH_SCALE;
};
// ── module: src/app.ts ──
__mods["src/app.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// APP — orchestration: input (click + drag), AI driver (time-sliced), sound,
// persistence, and the headless debug API on window.game.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.App = void 0;
const constants_1 = require("./constants");
const state_1 = require("./state");
const rules_1 = require("./rules");
const movegen_1 = require("./movegen");
const san_1 = require("./san");
const draws_1 = require("./draws");
const ai_1 = require("./ai");
const sound_1 = require("./sound");
const persistence_1 = require("./persistence");
const ui_1 = require("./ui");
class App {
constructor(settings) {
this.lastMove = null;
this.selected = null;
this.pendingPromo = null;
this.thinking = false;
this.gameOverMsg = null;
this.aiPending = false;
this.history = [];
this.els = {};
// --- AI driver (time-sliced root search with a hard budget) ---------------
this.aiRS = null;
this.aiBudget = 0;
this.game = (0, state_1.initialState)();
this.settings = settings || (0, persistence_1.defaultSettings)();
}
// --- lifecycle -----------------------------------------------------------
static restore() {
const data = (0, persistence_1.load)();
if (!data)
return null;
const app = new App(data.settings);
app.game = data.state;
app.history = data.entries;
app.game.history = app.history;
app.lastMove = data.lastMove;
app.gameOverMsg = null;
return app;
}
newGame() {
this.game = (0, state_1.initialState)();
this.history = [];
this.lastMove = null;
this.selected = null;
this.pendingPromo = null;
this.thinking = false;
this.aiPending = false;
this.aiRS = null;
this.gameOverMsg = null;
sound_1.sound.play('start');
this.persist();
this.paint();
if (this.settings.aiColor !== 'none' && this.game.turn === this.settings.aiColor) {
this.scheduleAI();
}
}
humanColor() {
return this.settings.aiColor === 'none' ? null : (0, state_1.otherColor)(this.settings.aiColor);
}
isGameOver() { return this.gameOverMsg !== null; }
// --- applying moves ------------------------------------------------------
applyMove(m, byAI) {
const entry = {
move: m,
prevBoard: this.game.board.slice(),
prevCastling: { ...this.game.castling },
prevEp: this.game.ep,
prevHalf: this.game.halfmove,
prevFull: this.game.fullmove,
prevPositionKey: (0, state_1.positionKey)(this.game),
positionKey: '',
san: (0, san_1.moveToSan)(this.game, m),
};
const ns = (0, state_1.makeMoveOnState)(this.game, m);
const counts = new Map(this.game.positionCounts);
const key = (0, state_1.positionKey)(ns);
counts.set(key, (counts.get(key) || 0) + 1);
this.history.push(entry);
this.game = {
...ns,
history: this.history,
positionCounts: counts,
};
entry.positionKey = key;
this.lastMove = { from: m.from, to: m.to };
this.selected = null;
this.pendingPromo = null;
// status first, so we know whether the game just ended
const st = (0, draws_1.fullStatus)(this.game);
this.recomputeStatus();
// sound
const opp = this.game.turn;
const checking = !this.isGameOver() && (0, rules_1.inCheck)(this.game.board, opp);
if (st.result === 'checkmate')
sound_1.sound.play('mate');
else if (st.result !== 'none')
sound_1.sound.play('start');
else if (checking)
sound_1.sound.play('check');
else if (m.captured || m.flags === 'ep')
sound_1.sound.play('capture');
else
sound_1.sound.play('move');
this.persist();
this.paint();
if (!this.isGameOver() && this.settings.aiColor !== 'none' && this.game.turn === this.settings.aiColor) {
this.scheduleAI();
}
void byAI;
}
// --- human input ---------------------------------------------------------
tryHumanMove(from, to) {
if (this.isGameOver() || this.thinking)
return false;
if (this.pendingPromo)
return false;
const human = this.humanColor();
if (human !== null && this.game.turn !== human)
return false;
const moves = (0, movegen_1.legalMovesFrom)(this.game, from);
const candidates = moves.filter(mm => mm.to === to);
if (candidates.length === 0)
return false;
const promo = candidates.find(c => c.flags.startsWith('promo:'));
if (promo) {
// show picker over the promotion square
this.selected = from;
this.pendingPromo = { from, to };
this.paint();
(0, ui_1.showPromoPicker)(this.view, this.game, to, this.settings.flipped, (pc) => {
const mv = (0, movegen_1.legalMovesFrom)(this.game, from).find(x => x.to === to && x.flags === 'promo:' + pc);
if (mv)
this.applyMove(mv, false);
else {
this.pendingPromo = null;
this.paint();
}
}, () => { this.pendingPromo = null; this.selected = null; this.paint(); });
return true;
}
this.applyMove(candidates[0], false);
return true;
}
scheduleAI() {
if (this.aiPending)
return;
const human = this.humanColor();
if (human === null)
return; // analysis mode
if (this.game.turn !== this.settings.aiColor || this.isGameOver())
return;
this.aiPending = true;
this.thinking = true;
const depth = Math.max(1, Math.min(3, this.settings.difficulty));
const budgetMs = constants_1.CONFIG.AI_BUDGET_MS[depth - 1] ?? constants_1.CONFIG.AI_BUDGET_MS[2];
this.aiBudget = performance.now() + budgetMs;
this.aiRS = (0, ai_1.prepareRoot)(this.game, depth);
this.paint();
setTimeout(() => this.aiStep(), 30);
}
aiStep() {
if (!this.aiPending || !this.aiRS)
return;
const slice = performance.now() + constants_1.CONFIG.AI_TIME_SLICE_MS;
const res = (0, ai_1.stepRoot)(this.aiRS, slice, this.aiBudget);
if (res === null) {
setTimeout(() => this.aiStep(), 8);
return;
}
this.aiPending = false;
this.thinking = false;
this.aiRS = null;
const m = res.move;
if (m)
this.applyMove(m, true);
else
this.paint();
}
// --- undo ----------------------------------------------------------------
doUndo() {
if (this.history.length === 0)
return;
this.aiPending = false;
this.thinking = false;
const human = this.humanColor();
// Undo takes back everything down to and including the human's last move.
// In analysis mode (human === null) it takes back a single move.
let count = 0;
if (human === null) {
count = 1;
}
else {
let idx = -1;
for (let i = this.history.length - 1; i >= 0; i--) {
if (this.history[i].move.piece[0] === human) {
idx = i;
break;
}
}
count = idx === -1 ? 1 : this.history.length - idx;
}
count = Math.min(count, this.history.length);
for (let i = 0; i < count; i++) {
this.popEntry(this.history[this.history.length - 1]);
}
this.recomputeStatus();
const top = this.history[this.history.length - 1];
this.lastMove = top ? { from: top.move.from, to: top.move.to } : null;
sound_1.sound.play('undo');
this.persist();
this.paint();
}
popEntry(e) {
this.history.pop();
const counts = new Map(this.game.positionCounts);
const c = counts.get(e.positionKey) || 1;
counts.set(e.positionKey, Math.max(0, c - 1));
this.game = {
board: e.prevBoard,
turn: e.move.piece[0],
castling: e.prevCastling,
ep: e.prevEp,
halfmove: e.prevHalf,
fullmove: e.prevFull,
history: this.history,
positionCounts: counts,
};
}
// --- settings ------------------------------------------------------------
setAiColor(c) {
this.settings.aiColor = c;
this.persist();
this.paint();
if (!this.isGameOver() && c !== 'none' && this.game.turn === c)
this.scheduleAI();
}
setDifficulty(d) {
this.settings.difficulty = d;
this.persist();
}
flip() {
this.settings.flipped = !this.settings.flipped;
this.persist();
this.paint();
}
toggleMute() {
const m = sound_1.sound.toggle();
this.settings.muted = m;
this.persist();
return m;
}
// --- persistence ---------------------------------------------------------
persist() {
const hist = this.history.map(e => ({
san: e.san, from: e.move.from, to: e.move.to, flags: e.move.flags,
piece: e.move.piece, captured: e.move.captured,
prevFen: (0, state_1.stateToFen)({
board: e.prevBoard, turn: e.move.piece[0],
castling: e.prevCastling, ep: e.prevEp,
halfmove: e.prevHalf, fullmove: e.prevFull,
}),
posKey: e.positionKey,
}));
(0, persistence_1.save)((0, state_1.stateToFen)(this.game), this.game.turn, hist, Array.from(this.game.positionCounts.entries()), this.settings, this.lastMove, this.gameOverMsg);
}
// --- painting ------------------------------------------------------------
paint() {
if (!this.view)
return;
const hl = {
selected: this.selected,
targets: this.selected !== null ? (0, movegen_1.legalMovesFrom)(this.game, this.selected) : [],
lastMove: this.lastMove,
checkSquare: (0, rules_1.inCheck)(this.game.board, this.game.turn) ? (0, rules_1.findKing)(this.game.board, this.game.turn) : null,
hoverSquare: null,
};
(0, ui_1.paintBoard)(this.view, this.game, this.settings.flipped, hl);
if (this.pendingPromo) {
// keep picker visible
}
else {
(0, ui_1.hidePromoPicker)(this.view);
}
const turn = this.game.turn;
const oppColor = (0, state_1.otherColor)(turn);
let label = this.isGameOver()
? 'Game over'
: this.thinking
? `Computer (${turn === 'w' ? 'White' : 'Black'}) thinking…`
: (this.humanColor() === null ? `${turn === 'w' ? 'White' : 'Black'} to move`
: (turn === this.humanColor() ? `Your move (${turn === 'w' ? 'White' : 'Black'})` : `Computer's move`));
const checking = !this.isGameOver() && (0, rules_1.inCheck)(this.game.board, turn);
(0, ui_1.setTurnLabel)(this.els['turn-label'], turn, checking, this.thinking, label);
// captured trays: "White captured" = black pieces white took; advantage
// shown next to the leading side
const trays = (0, ui_1.capturedTrays)(this.game);
this.els['tray-white'].innerHTML =
(0, ui_1.trayHTML)(trays.byWhite) + (trays.advantage > 0 ? `<span class="adv">+${trays.advantage}</span>` : '');
this.els['tray-black'].innerHTML =
(0, ui_1.trayHTML)(trays.byBlack) + (trays.advantage < 0 ? `<span class="adv">+${-trays.advantage}</span>` : '');
(0, ui_1.renderMoveList)(this.els['move-list'], this.history.map(h => h.san));
if (this.isGameOver())
(0, ui_1.showBanner)(this.els['banner'], this.gameOverMsg || '', (this.gameOverMsg || '').includes('Draw') ? 'draw' : 'win');
else
(0, ui_1.hideBanner)(this.els['banner']);
const undoBtn = this.els['btn-undo'];
undoBtn.disabled = this.history.length === 0;
const muteBtn = this.els['btn-mute'];
muteBtn._label = this.settings.muted ? '\u2669 mute' : '\u266A sound';
muteBtn.textContent = muteBtn._label;
}
// Recompute game-over state (used after loading a position directly)
recomputeStatus() {
const st = (0, draws_1.fullStatus)(this.game);
if (st.result === 'none') {
this.gameOverMsg = null;
return;
}
if (st.result === 'checkmate') {
this.gameOverMsg = `${st.winner === 'w' ? 'White' : 'Black'} wins by checkmate`;
}
else {
const names = {
stalemate: 'Draw — stalemate',
insufficient: 'Draw — insufficient material',
fifty: 'Draw — 50-move rule',
threefold: 'Draw — threefold repetition',
};
this.gameOverMsg = names[st.result] || 'Game over';
}
}
// --- input wiring --------------------------------------------------------
attach() {
const root = document.getElementById('app');
this.els = {
'board': root.querySelector('#board'),
'turn-label': root.querySelector('#turn-label'),
'tray-white': root.querySelector('#tray-white'),
'tray-black': root.querySelector('#tray-black'),
'move-list': root.querySelector('#move-list'),
'banner': root.querySelector('#banner'),
'btn-flip': root.querySelector('#btn-flip'),
'btn-undo': root.querySelector('#btn-undo'),
'btn-new': root.querySelector('#btn-new'),
'btn-mute': root.querySelector('#btn-mute'),
'sel-color': root.querySelector('#sel-color'),
'sel-diff': root.querySelector('#sel-diff'),
};
this.view = (0, ui_1.buildBoard)();
this.els['board'].appendChild(this.view.wrapper);
const boardEl = this.view.boardEl;
boardEl.addEventListener('contextmenu', e => e.preventDefault());
// --- pointer input: click AND drag -------------------------------------
let drag = null;
const displayCellAt = (x, y) => {
const el = document.elementFromPoint(x, y);
const cell = el ? el.closest('.sq') : null;
if (!cell || cell.dataset.idx === undefined)
return null;
return parseInt(cell.dataset.idx, 10);
};
const squareAtDisplay = (dispIdx) => {
const dr = Math.floor(dispIdx / 8), dc = dispIdx % 8;
return (0, ui_1.sqForCell)(dr, dc, this.settings.flipped);
};
boardEl.addEventListener('pointerdown', e => {
if (e.button !== 0)
return;
const idx = displayCellAt(e.clientX, e.clientY);
if (idx === null)
return;
const sq = squareAtDisplay(idx);
e.preventDefault();
if (this.isGameOver())
return;
// promotion picker open: board clicks cancel it
if (this.pendingPromo) {
(0, ui_1.hidePromoPicker)(this.view);
this.pendingPromo = null;
this.selected = null;
this.paint();
return;
}
if (this.thinking)
return;
drag = { from: sq, x: e.clientX, y: e.clientY, active: false, ghost: null };
try {
boardEl.setPointerCapture?.(e.pointerId);
}
catch { /* synthetic/inactive pointer */ }
});
boardEl.addEventListener('pointermove', e => {
// hover highlight (no button pressed)
if (!drag && (e.buttons === 0)) {
const idx = displayCellAt(e.clientX, e.clientY);
const sq = idx === null ? null : squareAtDisplay(idx);
// repaint with hover
const prevHover = this._hoverSq;
this._hoverSq = sq;
if (prevHover !== sq)
this.paintHover();
return;
}
if (!drag || e.buttons === 0)
return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.active && Math.hypot(dx, dy) > 8) {
// only own pieces are draggable
const p = this.game.board[drag.from];
const canDrag = p && p[0] === this.game.turn && (this.humanColor() === null || this.game.turn === this.humanColor());
if (!canDrag) {
drag = null;
return;
}
drag.active = true;
const glyph = this.game.board[drag.from][1];
const color = this.game.board[drag.from][0];
const ghost = document.createElement('div');
ghost.className = 'drag-ghost glyph ' + (color === 'w' ? 'wp' : 'bp');
ghost.textContent = ui_1.GLYPHS[glyph];
this.view.wrapper.appendChild(ghost);
drag.ghost = ghost;
this.selected = drag.from;
}
if (drag.active && drag.ghost) {
const wrap = this.view.wrapper.getBoundingClientRect();
drag.ghost.style.left = (e.clientX - wrap.left - 20) + 'px';
drag.ghost.style.top = (e.clientY - wrap.top - 24) + 'px';
}
});
const finishDrag = (e) => {
if (!drag)
return;
const d = drag;
drag = null;
if (d.ghost)
d.ghost.remove();
const idx = displayCellAt(e.clientX, e.clientY);
const toSq = idx === null ? null : squareAtDisplay(idx);
if (d.active) {
if (toSq !== null && toSq !== d.from) {
this.tryHumanMove(d.from, toSq);
}
else {
// dropped on itself: keep selection
this.selected = d.from;
this.paint();
}
}
else {
// simple click: select, or move if already selected
const p = this.game.board[d.from];
const own = p && (this.humanColor() === null ? true : p[0] === this.game.turn);
if (this.selected !== null && this.selected !== d.from && own === false) {
// click on empty/opponent: attempt move from selection
this.tryHumanMove(this.selected, d.from);
}
else if (this.selected === d.from) {
this.selected = null;
this.paint();
}
else if (own) {
this.selected = d.from;
this.paint();
}
else {
// click elsewhere while nothing selected: try to move from selection
if (this.selected !== null)
this.tryHumanMove(this.selected, d.from);
this.selected = null;
this.paint();
}
}
if (!d.active)
this.paint();
};
boardEl.addEventListener('pointerup', finishDrag);
boardEl.addEventListener('pointercancel', () => { if (drag && drag.ghost)
drag.ghost.remove(); drag = null; this.paint(); });
// --- buttons -----------------------------------------------------------
this.els['btn-flip'].addEventListener('click', () => this.flip());
this.els['btn-undo'].addEventListener('click', () => this.doUndo());
this.els['btn-new'].addEventListener('click', () => this.newGame());
this.els['btn-mute'].addEventListener('click', () => {
const m = this.toggleMute();
this.paint();
});
this.els['sel-color'].addEventListener('change', e => {
const v = e.target.value;
this.setAiColor(v);
});
this.els['sel-diff'].addEventListener('change', e => {
this.setDifficulty(parseInt(e.target.value, 10));
});
// reflect persisted settings in controls
const sel = this.els['sel-color'];
sel.value = this.settings.aiColor;
this.els['sel-diff'].value = String(this.settings.difficulty);
// resume the AI if the restored/setup position is its turn
if (!this.isGameOver() && this.settings.aiColor !== 'none' && this.game.turn === this.settings.aiColor) {
this.scheduleAI();
}
}
paintHover() {
if (!this.view)
return;
const hl = {
selected: this.selected,
targets: this.selected !== null ? (0, movegen_1.legalMovesFrom)(this.game, this.selected) : [],
lastMove: this.lastMove,
checkSquare: (0, rules_1.inCheck)(this.game.board, this.game.turn) ? (0, rules_1.findKing)(this.game.board, this.game.turn) : null,
hoverSquare: this._hoverSq,
};
(0, ui_1.paintBoard)(this.view, this.game, this.settings.flipped, hl);
}
// --- headless debug API --------------------------------------------------
debugApi() {
const app = this;
const toCode = (m) => {
let code = (0, state_1.sqToAlgebraic)(m.from) + (0, state_1.sqToAlgebraic)(m.to);
if (m.flags.startsWith('promo:'))
code += m.flags.slice(6).toLowerCase();
if (m.flags === 'ep')
code += 'e';
return code;
};
return {
// current state
state: () => app.game,
fen: () => (0, state_1.stateToFen)(app.game),
turn: () => app.game.turn,
// all legal moves as codes (e2e4, e7e8q, d5e6e)
legalMoves: () => (0, movegen_1.legalMoves)(app.game).map(toCode),
// pseudo-legal moves (pre-legality-filter) for debugging
pseudoMoves: () => {
const p = [];
(0, movegen_1.pseudoMoves)(app.game, p);
return p.map(toCode);
},
legalFrom: (sqCode) => {
const sq = (0, state_1.algebraicToSq)(String(sqCode));
if (sq === null)
return [];
return (0, movegen_1.legalMovesFrom)(app.game, sq).map(toCode);
},
attacked: (sqCode, color) => {
const sq = (0, state_1.algebraicToSq)(String(sqCode));
if (sq === null)
return false;
return (0, rules_1.isSquareAttacked)(app.game.board, sq, color === 'w' ? 'w' : 'b');
},
// play a move by code; returns SAN or null if illegal/unavailable
move: (code) => {
if (app.thinking || app.isGameOver())
return null;
const c = String(code).toLowerCase().replace('e.p.', '');
if (c.length < 4)
return null;
const from = (0, state_1.algebraicToSq)(c.slice(0, 2));
const to = (0, state_1.algebraicToSq)(c.slice(2, 4));
if (from === null || to === null)
return null;
const fifth = c.length >= 5 ? c.slice(4, 5) : null;
const promo = fifth === 'q' || fifth === 'r' || fifth === 'b' || fifth === 'n' ? fifth : null;
const legal = (0, movegen_1.legalMoves)(app.game);
const mv = legal.find(m => m.from === from && m.to === to &&
(promo ? m.flags === 'promo:' + promo.toUpperCase() : !m.flags.startsWith('promo:')));
if (!mv)
return null;
const san = (0, san_1.moveToSan)(app.game, mv);
app.applyMove(mv, false);
return san;
},
// helpers
newGame: () => app.newGame(),
undo: () => app.doUndo(),
setup: (fen) => {
const { fenToFullState } = requireFen();
app.game = fenToFullState(fen);
app.history = [];
app.lastMove = null;
app.selected = null;
app.pendingPromo = null;
app.thinking = false;
app.aiPending = false;
app.recomputeStatus();
app.persist();
app.paint();
if (!app.isGameOver() && app.settings.aiColor !== 'none' && app.game.turn === app.settings.aiColor) {
app.scheduleAI();
}
},
recompute: () => { app.recomputeStatus(); app.paint(); return (0, draws_1.fullStatus)(app.game).result; },
status: () => (0, draws_1.fullStatus)(app.game).result,
// static evaluation from the mover's perspective (centipawns)
evaluate: () => (0, ai_1.evaluate)(app.game),
};
}
}
exports.App = App;
function requireFen() {
return { fenToFullState: fen_1.fenToFullState };
}
const fen_1 = require("./fen");
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"3edca28c-6426-499b-be70-ff24c20d9909","tokensIn":15031570,"tokensOut":348504,"tokensTotal":15380074,"cost":0,"turns":162,"toolCalls":243,"failedToolCalls":25,"timestamp":"2026-09-09T01:26:17.709Z"} -->