diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..dcec89a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: CI / Deploy to GitHub Pages + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + # ── Validate ───────────────────────────────────────────────── + validate: + name: Validate HTML & JS + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install validators + run: npm install -g html-validate + + - name: Validate HTML + run: html-validate index.html + + # ── Deploy (only on push to main) ──────────────────────────── + deploy: + name: Deploy to GitHub Pages + needs: validate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + permissions: + contents: read + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure GitHub Pages + uses: actions/configure-pages@v4 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + # Deploy everything in the repo root + path: '.' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e918b09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Dependencies installed locally (vendored copy in /vendor is committed) +node_modules/ +package-lock.json + +# OS artefacts +.DS_Store +Thumbs.db + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log diff --git a/.htmlvalidate.json b/.htmlvalidate.json new file mode 100644 index 0000000..695b527 --- /dev/null +++ b/.htmlvalidate.json @@ -0,0 +1,8 @@ +{ + "extends": ["html-validate:recommended"], + "rules": { + "no-inline-style": "off", + "svg-focusable": "off", + "long-title": "off" + } +} diff --git a/README.md b/README.md index 6ac7c90..22f0f0c 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -# SQL_Chess \ No newline at end of file +# ♟ SQL Chess + +> Play chess — and watch every move translated into real SQL queries in real time. + +[![Deploy to GitHub Pages](https://github.com/Devn913/SQL_Chess/actions/workflows/deploy.yml/badge.svg)](https://github.com/Devn913/SQL_Chess/actions/workflows/deploy.yml) + +**Live demo:** https://devn913.github.io/SQL_Chess/ + +--- + +## Features + +| Feature | Details | +|---|---| +| ♟ **Fully playable chess** | All rules enforced via [chess.js](https://github.com/jhlywa/chess.js) — en passant, castling, pawn promotion, check/checkmate/stalemate | +| ⬡ **SQL Panel** | Every move generates real `INSERT`, `UPDATE`, `DELETE` SQL statements with syntax highlighting | +| □ **Toggle SQL** | Hide the SQL panel to play as a traditional chess board | +| 👤 **Guest mode** | No login required — just open the page and play | +| ⇗ **Invite link** | Click "Invite" to generate a shareable URL that encodes the full game state — anyone who opens it continues the same game | +| ⇅ **Flip board** | Swap perspective between white and black | +| ↩ **Undo** | Take back the last move | + +## How the SQL works + +Each game gets its own `game_id`. Three tables are used: + +```sql +chess_game -- one row per game (id, players, status, winner) +chess_piece -- one row per piece (position updated on every move) +chess_move -- one row per move (full audit log) +``` + +Example — white pawn e2 → e4: + +```sql +INSERT INTO chess_move (game_id, move_number, color, piece_type, from_square, to_square, san) +VALUES ('a1b2-c3d4-e5f6-g7h8', 1, 'white', 'pawn', 'e2', 'e4', 'e4'); + +UPDATE chess_piece +SET position = 'e4' +WHERE game_id = 'a1b2-c3d4-e5f6-g7h8' + AND position = 'e2' + AND color = 'white'; +``` + +## CI/CD + +The repository uses **GitHub Actions** (`.github/workflows/deploy.yml`): + +1. **Validate** — runs `html-validate` on every push/PR to `main` +2. **Deploy** — automatically publishes the site to **GitHub Pages** on every push to `main` + +## Local development + +No build step required — it's a plain HTML/CSS/JS site. + +```bash +# Clone and open +git clone https://github.com/Devn913/SQL_Chess.git +cd SQL_Chess +# Open index.html in your browser, or serve with any static server: +npx serve . +``` + +## License + +[MIT](LICENSE) diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..b21e856 --- /dev/null +++ b/css/style.css @@ -0,0 +1,760 @@ +/* ============================================================ + SQL Chess — Global Styles + Dark theme, responsive layout + ============================================================ */ + +/* ── Custom Properties ─────────────────────────────────────── */ +:root { + --bg-base: #0d1117; + --bg-surface: #161b22; + --bg-elevated: #21262d; + --bg-border: #30363d; + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #484f58; + + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-orange: #d29922; + --accent-red: #f85149; + --accent-purple: #bc8cff; + + --board-light: #f0d9b5; + --board-dark: #b58863; + --board-select: #7fc97f; + --board-move: rgba(127, 201, 127, 0.45); + --board-check: rgba(248, 81, 73, 0.75); + --board-last-from: rgba(155, 199, 232, 0.4); + --board-last-to: rgba(155, 199, 232, 0.55); + + --sql-bg: #0d1117; + --sql-keyword: #ff7b72; + --sql-string: #a5d6ff; + --sql-comment: #8b949e; + --sql-number: #79c0ff; + --sql-function: #d2a8ff; + + --radius: 6px; + --radius-lg: 12px; + + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + + --shadow-sm: 0 1px 4px rgba(0,0,0,.4); + --shadow-md: 0 4px 16px rgba(0,0,0,.5); + --shadow-lg: 0 8px 32px rgba(0,0,0,.6); +} + +/* ── Reset ─────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: var(--font-sans); + background: var(--bg-base); + color: var(--text-primary); + line-height: 1.5; + overflow-x: hidden; +} + +button { cursor: pointer; font-family: inherit; } +input { font-family: inherit; } +a { color: var(--accent-blue); } + +/* ── App Shell ─────────────────────────────────────────────── */ +#app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── Header ────────────────────────────────────────────────── */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + height: 56px; + background: var(--bg-surface); + border-bottom: 1px solid var(--bg-border); + position: sticky; + top: 0; + z-index: 100; + flex-shrink: 0; +} + +.header-logo { + display: flex; + align-items: center; + gap: .5rem; + font-size: 1.2rem; + font-weight: 700; + user-select: none; +} +.logo-icon { font-size: 1.5rem; } +.logo-accent { color: var(--accent-blue); } + +.header-controls { + display: flex; + align-items: center; + gap: .5rem; +} + +/* ── Buttons ────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .4rem .85rem; + border-radius: var(--radius); + border: 1px solid var(--bg-border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: .875rem; + font-weight: 500; + transition: background .15s, border-color .15s, color .15s, box-shadow .15s; + white-space: nowrap; +} +.btn:hover { background: #2d333b; border-color: #8b949e; } +.btn:active { transform: translateY(1px); } + +.btn-primary { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #0d1117; +} +.btn-primary:hover { background: #79bcff; border-color: #79bcff; } + +.btn-secondary { + background: var(--bg-elevated); + border-color: var(--accent-purple); + color: var(--accent-purple); +} +.btn-secondary:hover { background: #2d2152; } + +.btn-outline { + background: transparent; + border-color: var(--bg-border); + color: var(--text-secondary); +} +.btn-outline:hover { background: var(--bg-elevated); color: var(--text-primary); } + +.btn-sm { padding: .3rem .65rem; font-size: .8rem; } +.btn-xs { padding: .2rem .5rem; font-size: .75rem; } +.btn-wide { width: 100%; justify-content: center; } + +/* ── Main Layout ────────────────────────────────────────────── */ +.app-main { + display: flex; + flex: 1; + gap: 0; + overflow: hidden; +} + +/* ── Chess Panel ────────────────────────────────────────────── */ +.chess-panel { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + gap: .6rem; + flex-shrink: 0; + min-width: 0; + overflow-y: auto; +} + +/* Player bars */ +.player-bar { + display: flex; + align-items: center; + gap: .6rem; + width: 100%; + max-width: 480px; + padding: .4rem .6rem; + background: var(--bg-surface); + border: 1px solid var(--bg-border); + border-radius: var(--radius); +} + +.player-avatar { + font-size: 1.4rem; + width: 2rem; + text-align: center; + flex-shrink: 0; +} +.white-avatar { filter: drop-shadow(0 0 3px rgba(255,255,255,.4)); } +.black-avatar { filter: drop-shadow(0 0 3px rgba(0,0,0,.6)); } + +.player-details { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.player-name { + font-weight: 600; + font-size: .9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.player-badge { + font-size: .75rem; + color: var(--accent-green); + font-weight: 500; +} + +.captured-strip { + display: flex; + flex-wrap: wrap; + gap: 1px; + font-size: .95rem; + min-height: 1.2rem; + flex: 1; + justify-content: flex-end; +} + +/* ── Board ──────────────────────────────────────────────────── */ +.board-wrapper { + position: relative; + display: grid; + grid-template-columns: 1.2rem auto; + grid-template-rows: auto 1.2rem; + gap: 2px; +} + +.rank-labels { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + justify-content: space-around; +} +.rank-label { + font-size: .7rem; + color: var(--text-secondary); + text-align: center; + line-height: 1; +} + +.file-labels { + grid-column: 2; + grid-row: 2; + display: flex; + justify-content: space-around; +} +.file-label { + font-size: .7rem; + color: var(--text-secondary); + text-align: center; + flex: 1; + line-height: 1; +} + +.board-grid { + grid-column: 2; + grid-row: 1; + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); + border: 2px solid #555; + border-radius: 3px; + overflow: hidden; + box-shadow: var(--shadow-lg); + /* Size is set dynamically in JS but default here */ + width: min(57vw, 460px); + height: min(57vw, 460px); +} + +/* Individual squares */ +.sq { + display: flex; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + user-select: none; + transition: background .1s; +} +.sq.light { background: var(--board-light); } +.sq.dark { background: var(--board-dark); } + +.sq:hover { filter: brightness(1.12); } + +/* Highlights */ +.sq.selected { background: var(--board-select) !important; } +.sq.valid-move::after { + content: ''; + position: absolute; + width: 32%; + height: 32%; + border-radius: 50%; + background: rgba(0,0,0,.2); + pointer-events: none; +} +.sq.valid-capture::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 4px solid rgba(0,0,0,.2); + pointer-events: none; + width: auto; + height: auto; +} +.sq.last-from { background: var(--board-last-from) !important; } +.sq.last-to { background: var(--board-last-to) !important; } +.sq.in-check { background: var(--board-check) !important; } + +/* Pieces */ +.piece { + font-size: calc(min(57vw, 460px) / 10); + line-height: 1; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + text-shadow: 1px 1px 2px rgba(0,0,0,.5); + transition: transform .1s; + position: relative; + z-index: 1; +} +.sq:hover .piece { transform: scale(1.05); } + +/* ── Game Controls ──────────────────────────────────────────── */ +.game-controls { + display: flex; + align-items: center; + gap: .5rem; + width: 100%; + max-width: 480px; +} + +.game-status-text { + margin-left: auto; + font-size: .85rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* ── Move History ───────────────────────────────────────────── */ +.move-history-box { + width: 100%; + max-width: 480px; + background: var(--bg-surface); + border: 1px solid var(--bg-border); + border-radius: var(--radius); + overflow: hidden; + flex-shrink: 0; +} +.move-history-header { + padding: .4rem .8rem; + font-size: .8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-secondary); + border-bottom: 1px solid var(--bg-border); + background: var(--bg-elevated); +} +.moves-list { + display: flex; + flex-wrap: wrap; + gap: .25rem; + padding: .5rem .6rem; + min-height: 2.5rem; + max-height: 120px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: .8rem; +} +.move-pair { + display: flex; + align-items: center; + gap: .2rem; + color: var(--text-secondary); +} +.move-num { color: var(--text-muted); margin-right: .1rem; } +.move-san { + color: var(--text-primary); + padding: .1rem .3rem; + border-radius: 3px; + cursor: pointer; + transition: background .1s; +} +.move-san:hover { background: var(--bg-elevated); } +.move-san.active { background: var(--accent-blue); color: #0d1117; } + +/* ── SQL Panel ──────────────────────────────────────────────── */ +.sql-panel { + flex: 1; + display: flex; + flex-direction: column; + border-left: 1px solid var(--bg-border); + background: var(--sql-bg); + overflow: hidden; + min-width: 0; + transition: width .25s ease, flex .25s ease, opacity .2s; +} +.sql-panel.hidden-panel { + flex: 0; + width: 0; + opacity: 0; + pointer-events: none; + border-left: none; +} + +.sql-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: .6rem 1rem; + border-bottom: 1px solid var(--bg-border); + background: var(--bg-surface); + flex-shrink: 0; +} +.sql-title { + font-size: .95rem; + font-weight: 700; + display: flex; + align-items: center; + gap: .4rem; + color: var(--accent-blue); +} +.sql-icon { font-size: .9rem; } +.sql-actions { + display: flex; + align-items: center; + gap: .5rem; +} +.autoscroll-label { + display: flex; + align-items: center; + gap: .3rem; + font-size: .75rem; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +/* SQL content area */ +.sql-content { + flex: 1; + overflow-y: auto; + padding: .75rem 1rem; + display: flex; + flex-direction: column; + gap: .75rem; + scroll-behavior: smooth; +} + +.sql-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .75rem; + flex: 1; + color: var(--text-muted); + text-align: center; + padding: 2rem; +} +.placeholder-icon { font-size: 2.5rem; opacity: .3; } +.sql-placeholder p { font-size: .9rem; } +.placeholder-hint { font-size: .8rem; line-height: 1.6; color: var(--text-muted); opacity: .7; } + +/* SQL blocks */ +.sql-block { + background: var(--bg-elevated); + border: 1px solid var(--bg-border); + border-radius: var(--radius); + overflow: hidden; + animation: fadeSlideIn .2s ease; +} +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.sql-block-label { + display: flex; + align-items: center; + gap: .4rem; + padding: .35rem .75rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--bg-border); + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-secondary); +} +.sql-block-badge { + background: var(--accent-blue); + color: #0d1117; + padding: .05rem .35rem; + border-radius: 99px; + font-size: .65rem; +} + +.sql-code { + padding: .75rem; + font-family: var(--font-mono); + font-size: .78rem; + line-height: 1.65; + white-space: pre; + overflow-x: auto; + color: var(--text-primary); +} +/* SQL syntax colouring */ +.sql-kw { color: var(--sql-keyword); font-weight: 700; } +.sql-str { color: var(--sql-string); } +.sql-cmt { color: var(--sql-comment); font-style: italic; } +.sql-num { color: var(--sql-number); } +.sql-fn { color: var(--sql-function); } + +/* ── Modals ─────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + padding: 1rem; +} +.modal-overlay.hidden { display: none; } + +.modal { + background: var(--bg-surface); + border: 1px solid var(--bg-border); + border-radius: var(--radius-lg); + width: 100%; + max-width: 440px; + box-shadow: var(--shadow-lg); + animation: modalIn .2s ease; +} +@keyframes modalIn { + from { opacity: 0; transform: scale(.96) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.modal-sm { max-width: 320px; } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem .75rem; + border-bottom: 1px solid var(--bg-border); +} +.modal-header h2 { font-size: 1rem; font-weight: 700; } + +.modal-close-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.1rem; + line-height: 1; + padding: .2rem .4rem; + border-radius: var(--radius); + transition: background .15s, color .15s; +} +.modal-close-btn:hover { background: var(--bg-elevated); color: var(--text-primary); } + +.modal-body { + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: .85rem; +} +.modal-footer { + display: flex; + gap: .6rem; + padding: .75rem 1.25rem 1rem; + flex-direction: column; +} + +.form-row { + display: flex; + flex-direction: column; + gap: .3rem; +} +.form-row label { + font-size: .82rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: .04em; +} +.form-row input[type="text"] { + background: var(--bg-elevated); + border: 1px solid var(--bg-border); + border-radius: var(--radius); + color: var(--text-primary); + padding: .5rem .7rem; + font-size: .9rem; + outline: none; + transition: border-color .15s; +} +.form-row input[type="text"]:focus { border-color: var(--accent-blue); } + +.toggle-row { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +/* Toggle switch */ +.switch { position: relative; display: inline-block; width: 40px; height: 22px; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { + position: absolute; + inset: 0; + background: var(--bg-border); + border-radius: 99px; + transition: background .2s; + cursor: pointer; +} +.slider::before { + content: ''; + position: absolute; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: transform .2s; +} +input:checked + .slider { background: var(--accent-blue); } +input:checked + .slider::before { transform: translateX(18px); } + +/* Invite modal */ +.invite-info { font-size: .875rem; color: var(--text-secondary); } +.invite-url-row { + display: flex; + gap: .5rem; +} +.invite-url-row input[type="text"] { + flex: 1; + background: var(--bg-elevated); + border: 1px solid var(--bg-border); + border-radius: var(--radius); + color: var(--text-primary); + padding: .45rem .65rem; + font-family: var(--font-mono); + font-size: .78rem; + outline: none; + cursor: text; + min-width: 0; +} +.invite-meta { + display: flex; + gap: 1rem; + font-size: .82rem; + color: var(--text-secondary); +} +.invite-meta strong { color: var(--text-primary); } + +.copy-feedback { + color: var(--accent-green); + font-size: .82rem; + font-weight: 600; +} + +/* Promotion */ +.promotion-choices { + display: flex; + justify-content: center; + gap: .5rem; + padding: .5rem 0; +} +.promotion-btn { + width: 64px; + height: 64px; + font-size: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: 2px solid var(--bg-border); + border-radius: var(--radius); + cursor: pointer; + color: var(--text-primary); + transition: border-color .15s, background .15s; +} +.promotion-btn:hover { border-color: var(--accent-blue); background: #1c2431; } + +/* ── Toast ──────────────────────────────────────────────────── */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--bg-elevated); + border: 1px solid var(--bg-border); + color: var(--text-primary); + padding: .6rem 1.25rem; + border-radius: 99px; + font-size: .875rem; + font-weight: 500; + box-shadow: var(--shadow-md); + z-index: 9999; + white-space: nowrap; + animation: toastIn .2s ease; +} +.toast.hidden { display: none; } +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ── Scrollbar ──────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #555; } + +/* ── Utility ────────────────────────────────────────────────── */ +.hidden { display: none !important; } + +/* ── Responsive ─────────────────────────────────────────────── */ +@media (max-width: 900px) { + .app-main { flex-direction: column; } + + .sql-panel { + border-left: none; + border-top: 1px solid var(--bg-border); + max-height: 40vh; + } + .sql-panel.hidden-panel { + max-height: 0; + border-top: none; + } + + .board-grid { + width: min(90vw, 420px); + height: min(90vw, 420px); + } + + .piece { + font-size: calc(min(90vw, 420px) / 10); + } +} + +@media (max-width: 480px) { + .app-header { padding: 0 .75rem; } + .header-logo .logo-text { display: none; } + .chess-panel { padding: .5rem; } + .header-controls { gap: .3rem; } + + .board-grid { + width: min(92vw, 360px); + height: min(92vw, 360px); + } + .piece { font-size: calc(min(92vw, 360px) / 10); } + + #btnToggleSQL #sqlToggleLabel { display: none; } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..b0f057f --- /dev/null +++ b/index.html @@ -0,0 +1,178 @@ + + + + + + SQL Chess — Play Chess with SQL + + + + + + + +
+ + +
+ +
+ + + +
+
+ + +
+ + +
+ + +
+
+
+ Guest Black + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+ Guest White + Your turn +
+
+
+ + +
+ + + White to move +
+ + +
+
Move History
+
+
+ +
+ + +
+
+

+ SQL Queries +

+
+ + + +
+
+
+
+ +

SQL queries will appear here as you play.

+

Each chess move is translated into
real SQL statements in real time.

+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..00d8d61 --- /dev/null +++ b/js/app.js @@ -0,0 +1,904 @@ +/** + * SQL Chess — Main Application + * A chess game where every move is translated into SQL queries in real time. + * + * Features: + * - Fully playable chess (chess.js engine) + * - SQL query panel showing CREATE/INSERT/UPDATE/DELETE for every move + * - Toggle SQL panel on/off + * - Play as Guest (no login required) + * - Invite system: generates a shareable URL with full game state + * - Pawn promotion dialog + * - Undo, flip board + * - Load game from invite URL on page load + */ + +'use strict'; + +/* ─── Configuration ──────────────────────────────────────────── */ +const PIECE_UNICODE = { + wK: '♔', wQ: '♕', wR: '♖', wB: '♗', wN: '♘', wP: '♙', + bK: '♚', bQ: '♛', bR: '♜', bB: '♝', bN: '♞', bP: '♟', +}; + +const PIECE_NAMES = { k: 'king', q: 'queen', r: 'rook', b: 'bishop', n: 'knight', p: 'pawn' }; + +const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; +const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1']; // top→bottom visual + +/* ─── State ───────────────────────────────────────────────────── */ +const state = { + chess: null, + gameId: null, + selectedSquare: null, + validMoves: [], + flipped: false, + showSQL: true, + sqlBlocks: [], + moveCount: 0, + whitePlayer: 'Guest White', + blackPlayer: 'Guest Black', + lastMove: null, + pendingPromotion: null, // { from, to } while waiting for user to pick + capturedByWhite: [], // pieces taken by white (black pieces lost) + capturedByBlack: [], // pieces taken by black (white pieces lost) +}; + +/* ─── Helpers ─────────────────────────────────────────────────── */ +function generateId() { + return Array.from({ length: 4 }, () => + Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0') + ).join('-'); +} + +function pieceName(type) { return PIECE_NAMES[type] || type; } + +function pieceSymbol(color, type) { + return PIECE_UNICODE[(color === 'w' ? 'w' : 'b') + type.toUpperCase()] || ''; +} + +function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } + +function showToast(msg, duration = 2500) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.remove('hidden'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), duration); +} + +/* ─── Board Rendering ─────────────────────────────────────────── */ +function buildBoard() { + const board = document.getElementById('board'); + board.innerHTML = ''; + const rankLabels = document.getElementById('rankLabels'); + const fileLabels = document.getElementById('fileLabels'); + rankLabels.innerHTML = ''; + fileLabels.innerHTML = ''; + + const ranks = state.flipped ? ['1','2','3','4','5','6','7','8'] : ['8','7','6','5','4','3','2','1']; + const files = state.flipped ? ['h','g','f','e','d','c','b','a'] : ['a','b','c','d','e','f','g','h']; + + ranks.forEach(rank => { + const lbl = document.createElement('div'); + lbl.className = 'rank-label'; + lbl.textContent = rank; + rankLabels.appendChild(lbl); + }); + files.forEach(file => { + const lbl = document.createElement('div'); + lbl.className = 'file-label'; + lbl.textContent = file; + fileLabels.appendChild(lbl); + }); + + ranks.forEach(rank => { + files.forEach(file => { + const sq = document.createElement('div'); + const sqName = file + rank; + const isDark = (file.charCodeAt(0) + parseInt(rank)) % 2 === 0; + sq.className = 'sq ' + (isDark ? 'dark' : 'light'); + sq.dataset.square = sqName; + sq.addEventListener('click', () => onSquareClick(sqName)); + board.appendChild(sq); + }); + }); +} + +function renderPieces() { + const boardState = state.chess.board(); + document.querySelectorAll('.sq').forEach(sqEl => { + sqEl.innerHTML = ''; + sqEl.classList.remove('selected', 'valid-move', 'valid-capture', 'last-from', 'last-to', 'in-check'); + + const sqName = sqEl.dataset.square; + const file = sqName.charCodeAt(0) - 97; + const rank = parseInt(sqName[1]) - 1; + const piece = boardState[7 - rank][file]; + + if (piece) { + const span = document.createElement('span'); + span.className = 'piece'; + span.textContent = pieceSymbol(piece.color, piece.type); + sqEl.appendChild(span); + } + + // Highlight last move + if (state.lastMove) { + if (sqName === state.lastMove.from) sqEl.classList.add('last-from'); + if (sqName === state.lastMove.to) sqEl.classList.add('last-to'); + } + }); + + // Highlight selected + valid moves + if (state.selectedSquare) { + const sel = document.querySelector(`.sq[data-square="${state.selectedSquare}"]`); + if (sel) sel.classList.add('selected'); + state.validMoves.forEach(mv => { + const target = document.querySelector(`.sq[data-square="${mv.to}"]`); + if (target) { + target.classList.add(mv.captured ? 'valid-capture' : 'valid-move'); + } + }); + } + + // Highlight check + if (state.chess.in_check()) { + const turn = state.chess.turn(); + const kingType = 'k'; + state.chess.board().forEach((row, ri) => { + row.forEach((cell, fi) => { + if (cell && cell.color === turn && cell.type === kingType) { + const sqName = FILES[fi] + (8 - ri); + const sqEl = document.querySelector(`.sq[data-square="${sqName}"]`); + if (sqEl) sqEl.classList.add('in-check'); + } + }); + }); + } +} + +function updateCapturedStrips() { + const fmt = (arr) => arr.map(p => pieceSymbol(p.color, p.type)).join(''); + document.getElementById('whiteCapturedStrip').textContent = fmt(state.capturedByWhite); + document.getElementById('blackCapturedStrip').textContent = fmt(state.capturedByBlack); +} + +function updatePlayerBars() { + const turn = state.chess.turn(); + document.getElementById('whiteBadge').textContent = turn === 'w' ? 'Your turn' : ''; + document.getElementById('blackBadge').textContent = turn === 'b' ? 'Your turn' : ''; + document.getElementById('whiteBar').style.opacity = turn === 'w' ? '1' : '.65'; + document.getElementById('blackBar').style.opacity = turn === 'b' ? '1' : '.65'; +} + +function updateStatus() { + const el = document.getElementById('gameStatusText'); + const chess = state.chess; + if (chess.in_checkmate()) { + const winner = chess.turn() === 'w' ? state.blackPlayer : state.whitePlayer; + el.textContent = `♛ Checkmate — ${winner} wins!`; + el.style.color = 'var(--accent-red)'; + } else if (chess.in_stalemate()) { + el.textContent = '½-½ Stalemate'; + el.style.color = 'var(--accent-orange)'; + } else if (chess.in_draw()) { + el.textContent = '½-½ Draw'; + el.style.color = 'var(--accent-orange)'; + } else if (chess.in_check()) { + const who = chess.turn() === 'w' ? state.whitePlayer : state.blackPlayer; + el.textContent = `⚠ Check — ${who}`; + el.style.color = 'var(--accent-red)'; + } else { + const who = chess.turn() === 'w' ? state.whitePlayer : state.blackPlayer; + el.textContent = `${who} to move`; + el.style.color = 'var(--text-secondary)'; + } +} + +function buildMoveHistory() { + const list = document.getElementById('movesList'); + const pgn = state.chess.pgn({ max_width: 80, newline_char: ' ' }); + const moves = state.chess.history(); + list.innerHTML = ''; + for (let i = 0; i < moves.length; i += 2) { + const num = Math.floor(i / 2) + 1; + const pair = document.createElement('div'); + pair.className = 'move-pair'; + pair.innerHTML = + `${num}.` + + `${moves[i]}` + + (moves[i + 1] ? `${moves[i + 1]}` : ''); + list.appendChild(pair); + } + list.scrollTop = list.scrollHeight; +} + +function renderAll() { + renderPieces(); + updateCapturedStrips(); + updatePlayerBars(); + updateStatus(); + buildMoveHistory(); +} + +/* ─── Square Click Logic ──────────────────────────────────────── */ +function onSquareClick(sqName) { + // Ignore clicks if game over or awaiting promotion + if (state.chess.game_over()) return; + if (state.pendingPromotion) return; + + const piece = state.chess.get(sqName); + + // If no square selected yet + if (!state.selectedSquare) { + if (!piece) return; + if (piece.color !== state.chess.turn()) return; // not your piece + state.selectedSquare = sqName; + state.validMoves = state.chess.moves({ square: sqName, verbose: true }); + renderPieces(); + return; + } + + // Already have a selected square + if (sqName === state.selectedSquare) { + // Deselect + state.selectedSquare = null; + state.validMoves = []; + renderPieces(); + return; + } + + // Re-select if clicking own piece + if (piece && piece.color === state.chess.turn()) { + state.selectedSquare = sqName; + state.validMoves = state.chess.moves({ square: sqName, verbose: true }); + renderPieces(); + return; + } + + // Attempt a move + const moveObj = state.validMoves.find(m => m.to === sqName); + if (!moveObj) { + // Invalid target — deselect + state.selectedSquare = null; + state.validMoves = []; + renderPieces(); + return; + } + + // Pawn promotion? + if (moveObj.flags.includes('p')) { + state.pendingPromotion = { from: state.selectedSquare, to: sqName }; + state.selectedSquare = null; + state.validMoves = []; + openPromotionDialog(state.chess.turn()); + return; + } + + executeMove(state.selectedSquare, sqName, null); +} + +function executeMove(from, to, promotion) { + const moveResult = state.chess.move({ from, to, promotion: promotion || 'q' }); + if (!moveResult) return; + + // Track captures + if (moveResult.captured) { + const capturedPiece = { color: moveResult.color === 'w' ? 'b' : 'w', type: moveResult.captured }; + if (moveResult.color === 'w') { + state.capturedByWhite.push(capturedPiece); + } else { + state.capturedByBlack.push(capturedPiece); + } + } + + state.lastMove = { from, to }; + state.selectedSquare = null; + state.validMoves = []; + state.moveCount++; + + // Generate SQL + if (state.showSQL) { + const sql = SQLGen.move(moveResult, state.moveCount, state.gameId); + appendSQL(sql, `Move ${Math.ceil(state.moveCount / 2)} — ${capitalize(pieceName(moveResult.piece))} ${from}→${to}`, state.moveCount); + + // Game-end SQL + if (state.chess.in_checkmate() || state.chess.in_stalemate() || state.chess.in_draw()) { + const endSQL = SQLGen.gameEnd(state.chess, state.gameId); + appendSQL(endSQL, 'Game Over', null); + } else if (state.chess.in_check()) { + const checkSQL = SQLGen.check(state.chess.turn(), state.gameId); + appendSQL(checkSQL, '⚠ Check', null); + } + } + + renderAll(); + + // Show game-end toast + if (state.chess.in_checkmate()) { + const winner = state.chess.turn() === 'w' ? state.blackPlayer : state.whitePlayer; + showToast(`♛ Checkmate! ${winner} wins!`, 5000); + } else if (state.chess.in_stalemate()) { + showToast('½-½ Stalemate!', 4000); + } else if (state.chess.in_draw()) { + showToast('½-½ Draw!', 4000); + } +} + +/* ─── Promotion Dialog ────────────────────────────────────────── */ +function openPromotionDialog(turn) { + const overlay = document.getElementById('promotionOverlay'); + const choices = document.getElementById('promotionChoices'); + choices.innerHTML = ''; + const pieces = ['q', 'r', 'b', 'n']; + const names = { q: 'Queen', r: 'Rook', b: 'Bishop', n: 'Knight' }; + pieces.forEach(p => { + const btn = document.createElement('button'); + btn.className = 'promotion-btn'; + btn.title = names[p]; + btn.textContent = pieceSymbol(turn, p); + btn.addEventListener('click', () => { + overlay.classList.add('hidden'); + const prom = state.pendingPromotion; + state.pendingPromotion = null; + executeMove(prom.from, prom.to, p); + }); + choices.appendChild(btn); + }); + overlay.classList.remove('hidden'); +} + +/* ─── SQL Generation ──────────────────────────────────────────── */ +const SQLGen = { + gameStart(gameId, whiteName, blackName) { + return [ + `-- ══════════════════════════════════════════`, + `-- SQL Chess · New Game`, + `-- Game ID : ${gameId}`, + `-- ══════════════════════════════════════════`, + ``, + `-- Schema (run once)`, + `CREATE TABLE IF NOT EXISTS chess_game (`, + ` id CHAR(36) NOT NULL,`, + ` white_player VARCHAR(100) NOT NULL,`, + ` black_player VARCHAR(100) NOT NULL,`, + ` status VARCHAR(20) NOT NULL DEFAULT 'active',`, + ` winner VARCHAR(10) DEFAULT NULL,`, + ` created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,`, + ` PRIMARY KEY (id)`, + `);`, + ``, + `CREATE TABLE IF NOT EXISTS chess_piece (`, + ` piece_id VARCHAR(15) NOT NULL,`, + ` game_id CHAR(36) NOT NULL,`, + ` color VARCHAR(5) NOT NULL,`, + ` type VARCHAR(10) NOT NULL,`, + ` position CHAR(2) DEFAULT NULL,`, + ` is_captured BOOLEAN NOT NULL DEFAULT FALSE,`, + ` PRIMARY KEY (piece_id, game_id),`, + ` FOREIGN KEY (game_id) REFERENCES chess_game(id)`, + `);`, + ``, + `CREATE TABLE IF NOT EXISTS chess_move (`, + ` id INT NOT NULL AUTO_INCREMENT,`, + ` game_id CHAR(36) NOT NULL,`, + ` move_number INT NOT NULL,`, + ` color VARCHAR(5) NOT NULL,`, + ` piece_type VARCHAR(10) NOT NULL,`, + ` from_square CHAR(2) NOT NULL,`, + ` to_square CHAR(2) NOT NULL,`, + ` captured VARCHAR(10) DEFAULT NULL,`, + ` special VARCHAR(20) DEFAULT NULL,`, + ` san VARCHAR(10) NOT NULL,`, + ` created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,`, + ` PRIMARY KEY (id),`, + ` FOREIGN KEY (game_id) REFERENCES chess_game(id)`, + `);`, + ``, + `-- Start new game`, + `INSERT INTO chess_game (id, white_player, black_player)`, + `VALUES ('${gameId}', '${whiteName}', '${blackName}');`, + ``, + `-- Set up all 32 pieces`, + `INSERT INTO chess_piece (piece_id, game_id, color, type, position) VALUES`, + ` ('wR_a1','${gameId}','white','rook', 'a1'),`, + ` ('wN_b1','${gameId}','white','knight','b1'),`, + ` ('wB_c1','${gameId}','white','bishop','c1'),`, + ` ('wQ_d1','${gameId}','white','queen', 'd1'),`, + ` ('wK_e1','${gameId}','white','king', 'e1'),`, + ` ('wB_f1','${gameId}','white','bishop','f1'),`, + ` ('wN_g1','${gameId}','white','knight','g1'),`, + ` ('wR_h1','${gameId}','white','rook', 'h1'),`, + ` ('wP_a2','${gameId}','white','pawn', 'a2'),`, + ` ('wP_b2','${gameId}','white','pawn', 'b2'),`, + ` ('wP_c2','${gameId}','white','pawn', 'c2'),`, + ` ('wP_d2','${gameId}','white','pawn', 'd2'),`, + ` ('wP_e2','${gameId}','white','pawn', 'e2'),`, + ` ('wP_f2','${gameId}','white','pawn', 'f2'),`, + ` ('wP_g2','${gameId}','white','pawn', 'g2'),`, + ` ('wP_h2','${gameId}','white','pawn', 'h2'),`, + ` ('bR_a8','${gameId}','black','rook', 'a8'),`, + ` ('bN_b8','${gameId}','black','knight','b8'),`, + ` ('bB_c8','${gameId}','black','bishop','c8'),`, + ` ('bQ_d8','${gameId}','black','queen', 'd8'),`, + ` ('bK_e8','${gameId}','black','king', 'e8'),`, + ` ('bB_f8','${gameId}','black','bishop','f8'),`, + ` ('bN_g8','${gameId}','black','knight','g8'),`, + ` ('bR_h8','${gameId}','black','rook', 'h8'),`, + ` ('bP_a7','${gameId}','black','pawn', 'a7'),`, + ` ('bP_b7','${gameId}','black','pawn', 'b7'),`, + ` ('bP_c7','${gameId}','black','pawn', 'c7'),`, + ` ('bP_d7','${gameId}','black','pawn', 'd7'),`, + ` ('bP_e7','${gameId}','black','pawn', 'e7'),`, + ` ('bP_f7','${gameId}','black','pawn', 'f7'),`, + ` ('bP_g7','${gameId}','black','pawn', 'g7'),`, + ` ('bP_h7','${gameId}','black','pawn', 'h7');`, + ].join('\n'); + }, + + move(mv, moveNum, gameId) { + const color = mv.color === 'w' ? 'white' : 'black'; + const oppColor = color === 'white' ? 'black' : 'white'; + const piece = pieceName(mv.piece); + const isCapture = !!mv.captured; + const isEP = mv.flags.includes('e'); + const isKCastle = mv.flags.includes('k'); + const isQCastle = mv.flags.includes('q'); + const isCastle = isKCastle || isQCastle; + const isPromo = mv.flags.includes('p'); + + let comment = `-- Move #${moveNum}: ${capitalize(color)} ${piece} ${mv.from} → ${mv.to}`; + if (isCapture && !isEP) comment = `-- Move #${moveNum}: ${capitalize(color)} ${piece} ${mv.from} ✕ ${mv.to} (captures ${pieceName(mv.captured)})`; + if (isEP) comment = `-- Move #${moveNum}: ${capitalize(color)} pawn ${mv.from} ✕ ${mv.to} (en passant)`; + if (isCastle) comment = `-- Move #${moveNum}: ${capitalize(color)} castles ${isKCastle ? 'kingside' : 'queenside'}`; + if (isPromo) comment += ` → promotes to ${pieceName(mv.promotion)}`; + + const capturedVal = (isCapture && !isEP) ? `'${pieceName(mv.captured)}'` : isEP ? `'pawn'` : 'NULL'; + const specialVal = isCastle ? `'${isKCastle ? 'kingside-castle' : 'queenside-castle'}'` + : isEP ? `'en-passant'` + : isPromo ? `'promotion-${pieceName(mv.promotion)}'` + : 'NULL'; + + const lines = [ + comment, + `INSERT INTO chess_move`, + ` (game_id, move_number, color, piece_type, from_square, to_square, captured, special, san)`, + `VALUES`, + ` ('${gameId}', ${moveNum}, '${color}', '${piece}', '${mv.from}', '${mv.to}', ${capturedVal}, ${specialVal}, '${mv.san}');`, + ``, + ]; + + if (isCapture && !isEP) { + lines.push(`-- Remove captured piece`); + lines.push(`DELETE FROM chess_piece`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.to}'`); + lines.push(` AND color = '${oppColor}';`); + lines.push(``); + lines.push(`-- Advance the capturing piece`); + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${mv.to}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.from}'`); + lines.push(` AND color = '${color}';`); + } else if (isEP) { + const capRank = color === 'white' ? parseInt(mv.to[1]) - 1 : parseInt(mv.to[1]) + 1; + const capSquare = mv.to[0] + capRank; + lines.push(`-- En passant: remove captured pawn`); + lines.push(`DELETE FROM chess_piece`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${capSquare}'`); + lines.push(` AND color = '${oppColor}';`); + lines.push(``); + lines.push(`-- Move capturing pawn`); + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${mv.to}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.from}'`); + lines.push(` AND color = '${color}';`); + } else if (isCastle) { + const rank = color === 'white' ? '1' : '8'; + const rookFrom = isKCastle ? `h${rank}` : `a${rank}`; + const rookTo = isKCastle ? `f${rank}` : `d${rank}`; + lines.push(`-- Move king`); + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${mv.to}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.from}'`); + lines.push(` AND color = '${color}';`); + lines.push(``); + lines.push(`-- Move rook (castling)`); + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${rookTo}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${rookFrom}'`); + lines.push(` AND color = '${color}';`); + } else if (isPromo) { + const promName = pieceName(mv.promotion); + if (isCapture) { + lines.push(`-- Remove captured piece`); + lines.push(`DELETE FROM chess_piece`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.to}'`); + lines.push(` AND color = '${oppColor}';`); + lines.push(``); + } + lines.push(`-- Promote pawn to ${promName}`); + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${mv.to}', type = '${promName}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.from}'`); + lines.push(` AND color = '${color}';`); + } else { + lines.push(`UPDATE chess_piece`); + lines.push(`SET position = '${mv.to}'`); + lines.push(`WHERE game_id = '${gameId}'`); + lines.push(` AND position = '${mv.from}'`); + lines.push(` AND color = '${color}';`); + } + + return lines.join('\n'); + }, + + check(turnAfterMove, gameId) { + const inCheck = turnAfterMove === 'w' ? 'white' : 'black'; + return [ + `-- ⚠ CHECK! The ${inCheck} king is now in check.`, + `UPDATE chess_game`, + `SET status = 'check'`, + `WHERE id = '${gameId}';`, + ].join('\n'); + }, + + gameEnd(chess, gameId) { + if (chess.in_checkmate()) { + const winner = chess.turn() === 'w' ? 'black' : 'white'; + return [ + `-- ♛ CHECKMATE! ${capitalize(winner)} wins!`, + `UPDATE chess_game`, + `SET status = 'checkmate',`, + ` winner = '${winner}'`, + `WHERE id = '${gameId}';`, + ].join('\n'); + } + if (chess.in_stalemate()) { + return [ + `-- ½-½ STALEMATE — the game is a draw.`, + `UPDATE chess_game`, + `SET status = 'stalemate'`, + `WHERE id = '${gameId}';`, + ].join('\n'); + } + return [ + `-- ½-½ DRAW`, + `UPDATE chess_game`, + `SET status = 'draw'`, + `WHERE id = '${gameId}';`, + ].join('\n'); + }, +}; + +/* ─── SQL Rendering ───────────────────────────────────────────── */ +function highlightSQL(code) { + // Simple keyword highlighting — no external lib needed + const keywords = [ + 'SELECT','FROM','WHERE','INSERT','INTO','VALUES','UPDATE','SET', + 'DELETE','CREATE','TABLE','IF','NOT','EXISTS','PRIMARY','KEY', + 'FOREIGN','REFERENCES','DEFAULT','NULL','BOOLEAN','AUTO_INCREMENT', + 'CHAR','VARCHAR','TIMESTAMP','INT','AND','OR','IN','AS', + ]; + const kwRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g'); + + return code + .replace(/&/g, '&').replace(//g, '>') + // Comments (-- …) + .replace(/(--[^\n]*)/g, '$1') + // Strings + .replace(/'([^']*)'/g, "'$1'") + // Numbers (standalone) + .replace(/\b(\d+)\b/g, '$1') + // Keywords + .replace(kwRegex, '$1'); +} + +function appendSQL(code, label, moveNum) { + const placeholder = document.getElementById('sqlPlaceholder'); + if (placeholder) placeholder.remove(); + + const content = document.getElementById('sqlContent'); + + const block = document.createElement('div'); + block.className = 'sql-block'; + + const labelEl = document.createElement('div'); + labelEl.className = 'sql-block-label'; + if (moveNum !== null) { + const badge = document.createElement('span'); + badge.className = 'sql-block-badge'; + badge.textContent = '#' + moveNum; + labelEl.appendChild(badge); + } + labelEl.append(' ' + label); + + const codeEl = document.createElement('div'); + codeEl.className = 'sql-code'; + codeEl.innerHTML = highlightSQL(code); + + block.appendChild(labelEl); + block.appendChild(codeEl); + content.appendChild(block); + + state.sqlBlocks.push({ code, label, moveNum }); + + if (document.getElementById('chkAutoScroll').checked) { + content.scrollTop = content.scrollHeight; + } +} + +/* ─── Game Lifecycle ──────────────────────────────────────────── */ +function startGame(whiteName, blackName, showSQL, existingPGN) { + state.chess = new Chess(); + state.gameId = generateId(); + state.selectedSquare = null; + state.validMoves = []; + state.lastMove = null; + state.moveCount = 0; + state.whitePlayer = whiteName || 'Guest White'; + state.blackPlayer = blackName || 'Guest Black'; + state.showSQL = showSQL !== false; + state.sqlBlocks = []; + state.capturedByWhite = []; + state.capturedByBlack = []; + state.pendingPromotion = null; + + // Names in UI + document.getElementById('whitePlayerName').textContent = state.whitePlayer; + document.getElementById('blackPlayerName').textContent = state.blackPlayer; + + // SQL panel visibility + const sqlPanel = document.getElementById('sqlPanel'); + const lblEl = document.getElementById('sqlToggleLabel'); + const iconEl = document.getElementById('sqlToggleIcon'); + if (state.showSQL) { + sqlPanel.classList.remove('hidden-panel'); + lblEl.textContent = 'Hide SQL'; + iconEl.textContent = '◧'; + } else { + sqlPanel.classList.add('hidden-panel'); + lblEl.textContent = 'Show SQL'; + iconEl.textContent = '□'; + } + + // Clear SQL content + const sqlContent = document.getElementById('sqlContent'); + sqlContent.innerHTML = ''; + if (state.showSQL) { + const placeholder = document.createElement('div'); + placeholder.id = 'sqlPlaceholder'; + placeholder.className = 'sql-placeholder'; + placeholder.innerHTML = + `` + + `

SQL queries will appear here as you play.

` + + `

Each chess move is translated into
real SQL statements in real time.

`; + sqlContent.appendChild(placeholder); + + // Emit game-start SQL + const initSQL = SQLGen.gameStart(state.gameId, state.whitePlayer, state.blackPlayer); + appendSQL(initSQL, 'Game Initialized', null); + } + + // Load from PGN if provided (invite link) + if (existingPGN && existingPGN.trim()) { + try { + state.chess.load_pgn(existingPGN); + // Replay SQL for every move + if (state.showSQL) { + const history = state.chess.history({ verbose: true }); + // Reset and replay + const tempChess = new Chess(); + history.forEach(mv => { + tempChess.move(mv); + state.moveCount++; + const sql = SQLGen.move(mv, state.moveCount, state.gameId); + appendSQL(sql, `Move ${Math.ceil(state.moveCount / 2)} — ${capitalize(pieceName(mv.piece))} ${mv.from}→${mv.to}`, state.moveCount); + if (mv.captured) { + const capturedPiece = { color: mv.color === 'w' ? 'b' : 'w', type: mv.captured }; + if (mv.color === 'w') state.capturedByWhite.push(capturedPiece); + else state.capturedByBlack.push(capturedPiece); + } + }); + // Set last move + if (history.length > 0) { + const last = history[history.length - 1]; + state.lastMove = { from: last.from, to: last.to }; + } + } + } catch (e) { + console.warn('Could not load PGN from invite link:', e); + } + } + + buildBoard(); + renderAll(); +} + +/* ─── Invite URL ──────────────────────────────────────────────── */ +function generateInviteURL() { + const pgn = state.chess.pgn() || ''; + const params = new URLSearchParams({ + w: state.whitePlayer, + b: state.blackPlayer, + sql: state.showSQL ? '1' : '0', + pgn: btoa(pgn), + }); + const base = window.location.origin + window.location.pathname; + return `${base}?${params.toString()}`; +} + +function loadFromURL() { + const params = new URLSearchParams(window.location.search); + if (!params.has('pgn')) return false; + const whiteName = params.get('w') || 'Guest White'; + const blackName = params.get('b') || 'Guest Black'; + const showSQL = params.get('sql') !== '0'; + let pgn = ''; + try { pgn = atob(params.get('pgn') || ''); } catch (e) { /* ignore */ } + startGame(whiteName, blackName, showSQL, pgn); + return true; +} + +/* ─── Event Wiring ────────────────────────────────────────────── */ +function init() { + // Always start with setup modal hidden; show it only when needed + document.getElementById('setupModalOverlay').classList.add('hidden'); + + // Try loading from invite URL first + const loadedFromURL = loadFromURL(); + + // Show setup modal unless loaded from invite + if (!loadedFromURL) { + document.getElementById('setupModalOverlay').classList.remove('hidden'); + } + + // New Game button → open setup modal + document.getElementById('btnNewGame').addEventListener('click', () => { + document.getElementById('setupModalOverlay').classList.remove('hidden'); + }); + + // Start Game + document.getElementById('btnStartGame').addEventListener('click', () => { + const wName = document.getElementById('inputWhiteName').value.trim() || 'Guest White'; + const bName = document.getElementById('inputBlackName').value.trim() || 'Guest Black'; + const showSql = document.getElementById('chkShowSQL').checked; + document.getElementById('setupModalOverlay').classList.add('hidden'); + startGame(wName, bName, showSql, null); + }); + + // Play as Guest (skip name entry) + document.getElementById('btnPlayAsGuest').addEventListener('click', () => { + document.getElementById('setupModalOverlay').classList.add('hidden'); + startGame('Guest White', 'Guest Black', true, null); + }); + + // Toggle SQL Panel + document.getElementById('btnToggleSQL').addEventListener('click', () => { + state.showSQL = !state.showSQL; + const sqlPanel = document.getElementById('sqlPanel'); + const lblEl = document.getElementById('sqlToggleLabel'); + const iconEl = document.getElementById('sqlToggleIcon'); + if (state.showSQL) { + sqlPanel.classList.remove('hidden-panel'); + lblEl.textContent = 'Hide SQL'; + iconEl.textContent = '◧'; + } else { + sqlPanel.classList.add('hidden-panel'); + lblEl.textContent = 'Show SQL'; + iconEl.textContent = '□'; + } + }); + + // Undo + document.getElementById('btnUndo').addEventListener('click', () => { + if (!state.chess) return; + const undone = state.chess.undo(); + if (!undone) return; + state.moveCount = Math.max(0, state.moveCount - 1); + state.lastMove = null; + // Fix captures + if (undone.captured) { + if (undone.color === 'w') state.capturedByWhite.pop(); + else state.capturedByBlack.pop(); + } + // Remove last SQL block from UI + const content = document.getElementById('sqlContent'); + if (content.lastChild && !content.lastChild.id) { + content.removeChild(content.lastChild); + state.sqlBlocks.pop(); + } + state.selectedSquare = null; + state.validMoves = []; + renderAll(); + }); + + // Flip board + document.getElementById('btnFlip').addEventListener('click', () => { + state.flipped = !state.flipped; + buildBoard(); + renderPieces(); + }); + + // Clear SQL + document.getElementById('btnClearSQL').addEventListener('click', () => { + const content = document.getElementById('sqlContent'); + content.innerHTML = ''; + state.sqlBlocks = []; + }); + + // Copy all SQL + document.getElementById('btnCopySQL').addEventListener('click', () => { + const allSQL = state.sqlBlocks.map(b => '-- ' + b.label + '\n' + b.code).join('\n\n'); + copyToClipboard(allSQL, 'All SQL copied!'); + }); + + // Invite button + document.getElementById('btnInvite').addEventListener('click', () => { + if (!state.chess) { + showToast('Start a game first!'); + return; + } + const url = generateInviteURL(); + document.getElementById('inviteUrlInput').value = url; + document.getElementById('inviteTurn').textContent = + state.chess.turn() === 'w' ? state.whitePlayer : state.blackPlayer; + document.getElementById('inviteMoves').textContent = state.moveCount; + document.getElementById('inviteModalOverlay').classList.remove('hidden'); + document.getElementById('copyFeedback').classList.add('hidden'); + }); + + document.getElementById('btnCloseInvite').addEventListener('click', () => { + document.getElementById('inviteModalOverlay').classList.add('hidden'); + }); + document.getElementById('inviteModalOverlay').addEventListener('click', (e) => { + if (e.target === document.getElementById('inviteModalOverlay')) { + document.getElementById('inviteModalOverlay').classList.add('hidden'); + } + }); + + document.getElementById('btnCopyInvite').addEventListener('click', () => { + const url = document.getElementById('inviteUrlInput').value; + copyToClipboard(url, null); + document.getElementById('copyFeedback').classList.remove('hidden'); + setTimeout(() => document.getElementById('copyFeedback').classList.add('hidden'), 2500); + }); + + // Close invite on overlay click already wired above +} + +function copyToClipboard(text, toastMsg) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => { + if (toastMsg) showToast(toastMsg); + }).catch(() => fallbackCopy(text, toastMsg)); + } else { + fallbackCopy(text, toastMsg); + } +} + +function fallbackCopy(text, toastMsg) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + if (toastMsg) showToast(toastMsg); + } catch (e) { + showToast('Copy failed — please copy manually'); + } + document.body.removeChild(ta); +} + +/* ─── Bootstrap ───────────────────────────────────────────────── */ +document.addEventListener('DOMContentLoaded', init); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2542c90 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "chess.js": "^0.10.3" + } +} diff --git a/vendor/chess.min.js b/vendor/chess.min.js new file mode 100755 index 0000000..120494d --- /dev/null +++ b/vendor/chess.min.js @@ -0,0 +1,1749 @@ +/* + * Copyright (c) 2020, Jeff Hlywa (jhlywa@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + *----------------------------------------------------------------------------*/ + +/* minified license below */ + +/* @license + * Copyright (c) 2018, Jeff Hlywa (jhlywa@gmail.com) + * Released under the BSD license + * https://github.com/jhlywa/chess.js/blob/master/LICENSE + */ + +var Chess = function(fen) { + var BLACK = 'b' + var WHITE = 'w' + + var EMPTY = -1 + + var PAWN = 'p' + var KNIGHT = 'n' + var BISHOP = 'b' + var ROOK = 'r' + var QUEEN = 'q' + var KING = 'k' + + var SYMBOLS = 'pnbrqkPNBRQK' + + var DEFAULT_POSITION = + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' + + var POSSIBLE_RESULTS = ['1-0', '0-1', '1/2-1/2', '*'] + + var PAWN_OFFSETS = { + b: [16, 32, 17, 15], + w: [-16, -32, -17, -15] + } + + var PIECE_OFFSETS = { + n: [-18, -33, -31, -14, 18, 33, 31, 14], + b: [-17, -15, 17, 15], + r: [-16, 1, 16, -1], + q: [-17, -16, -15, 1, 17, 16, 15, -1], + k: [-17, -16, -15, 1, 17, 16, 15, -1] + } + + // prettier-ignore + var ATTACKS = [ + 20, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0,20, 0, + 0,20, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0,20, 0, 0, + 0, 0,20, 0, 0, 0, 0, 24, 0, 0, 0, 0,20, 0, 0, 0, + 0, 0, 0,20, 0, 0, 0, 24, 0, 0, 0,20, 0, 0, 0, 0, + 0, 0, 0, 0,20, 0, 0, 24, 0, 0,20, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0,20, 2, 24, 2,20, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 2,53, 56, 53, 2, 0, 0, 0, 0, 0, 0, + 24,24,24,24,24,24,56, 0, 56,24,24,24,24,24,24, 0, + 0, 0, 0, 0, 0, 2,53, 56, 53, 2, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0,20, 2, 24, 2,20, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0,20, 0, 0, 24, 0, 0,20, 0, 0, 0, 0, 0, + 0, 0, 0,20, 0, 0, 0, 24, 0, 0, 0,20, 0, 0, 0, 0, + 0, 0,20, 0, 0, 0, 0, 24, 0, 0, 0, 0,20, 0, 0, 0, + 0,20, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0,20, 0, 0, + 20, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0,20 + ]; + + // prettier-ignore + var RAYS = [ + 17, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 15, 0, + 0, 17, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 15, 0, 0, + 0, 0, 17, 0, 0, 0, 0, 16, 0, 0, 0, 0, 15, 0, 0, 0, + 0, 0, 0, 17, 0, 0, 0, 16, 0, 0, 0, 15, 0, 0, 0, 0, + 0, 0, 0, 0, 17, 0, 0, 16, 0, 0, 15, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 17, 0, 16, 0, 15, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 17, 16, 15, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 0, -1, -1, -1,-1, -1, -1, -1, 0, + 0, 0, 0, 0, 0, 0,-15,-16,-17, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0,-15, 0,-16, 0,-17, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0,-15, 0, 0,-16, 0, 0,-17, 0, 0, 0, 0, 0, + 0, 0, 0,-15, 0, 0, 0,-16, 0, 0, 0,-17, 0, 0, 0, 0, + 0, 0,-15, 0, 0, 0, 0,-16, 0, 0, 0, 0,-17, 0, 0, 0, + 0,-15, 0, 0, 0, 0, 0,-16, 0, 0, 0, 0, 0,-17, 0, 0, + -15, 0, 0, 0, 0, 0, 0,-16, 0, 0, 0, 0, 0, 0,-17 + ]; + + var SHIFTS = { p: 0, n: 1, b: 2, r: 3, q: 4, k: 5 } + + var FLAGS = { + NORMAL: 'n', + CAPTURE: 'c', + BIG_PAWN: 'b', + EP_CAPTURE: 'e', + PROMOTION: 'p', + KSIDE_CASTLE: 'k', + QSIDE_CASTLE: 'q' + } + + var BITS = { + NORMAL: 1, + CAPTURE: 2, + BIG_PAWN: 4, + EP_CAPTURE: 8, + PROMOTION: 16, + KSIDE_CASTLE: 32, + QSIDE_CASTLE: 64 + } + + var RANK_1 = 7 + var RANK_2 = 6 + var RANK_3 = 5 + var RANK_4 = 4 + var RANK_5 = 3 + var RANK_6 = 2 + var RANK_7 = 1 + var RANK_8 = 0 + + // prettier-ignore + var SQUARES = { + a8: 0, b8: 1, c8: 2, d8: 3, e8: 4, f8: 5, g8: 6, h8: 7, + a7: 16, b7: 17, c7: 18, d7: 19, e7: 20, f7: 21, g7: 22, h7: 23, + a6: 32, b6: 33, c6: 34, d6: 35, e6: 36, f6: 37, g6: 38, h6: 39, + a5: 48, b5: 49, c5: 50, d5: 51, e5: 52, f5: 53, g5: 54, h5: 55, + a4: 64, b4: 65, c4: 66, d4: 67, e4: 68, f4: 69, g4: 70, h4: 71, + a3: 80, b3: 81, c3: 82, d3: 83, e3: 84, f3: 85, g3: 86, h3: 87, + a2: 96, b2: 97, c2: 98, d2: 99, e2: 100, f2: 101, g2: 102, h2: 103, + a1: 112, b1: 113, c1: 114, d1: 115, e1: 116, f1: 117, g1: 118, h1: 119 + }; + + var ROOKS = { + w: [ + { square: SQUARES.a1, flag: BITS.QSIDE_CASTLE }, + { square: SQUARES.h1, flag: BITS.KSIDE_CASTLE } + ], + b: [ + { square: SQUARES.a8, flag: BITS.QSIDE_CASTLE }, + { square: SQUARES.h8, flag: BITS.KSIDE_CASTLE } + ] + } + + var board = new Array(128) + var kings = { w: EMPTY, b: EMPTY } + var turn = WHITE + var castling = { w: 0, b: 0 } + var ep_square = EMPTY + var half_moves = 0 + var move_number = 1 + var history = [] + var header = {} + + /* if the user passes in a fen string, load it, else default to + * starting position + */ + if (typeof fen === 'undefined') { + load(DEFAULT_POSITION) + } else { + load(fen) + } + + function clear(keep_headers) { + if (typeof keep_headers === 'undefined') { + keep_headers = false + } + + board = new Array(128) + kings = { w: EMPTY, b: EMPTY } + turn = WHITE + castling = { w: 0, b: 0 } + ep_square = EMPTY + half_moves = 0 + move_number = 1 + history = [] + if (!keep_headers) header = {} + update_setup(generate_fen()) + } + + function reset() { + load(DEFAULT_POSITION) + } + + function load(fen, keep_headers) { + if (typeof keep_headers === 'undefined') { + keep_headers = false + } + + var tokens = fen.split(/\s+/) + var position = tokens[0] + var square = 0 + + if (!validate_fen(fen).valid) { + return false + } + + clear(keep_headers) + + for (var i = 0; i < position.length; i++) { + var piece = position.charAt(i) + + if (piece === '/') { + square += 8 + } else if (is_digit(piece)) { + square += parseInt(piece, 10) + } else { + var color = piece < 'a' ? WHITE : BLACK + put({ type: piece.toLowerCase(), color: color }, algebraic(square)) + square++ + } + } + + turn = tokens[1] + + if (tokens[2].indexOf('K') > -1) { + castling.w |= BITS.KSIDE_CASTLE + } + if (tokens[2].indexOf('Q') > -1) { + castling.w |= BITS.QSIDE_CASTLE + } + if (tokens[2].indexOf('k') > -1) { + castling.b |= BITS.KSIDE_CASTLE + } + if (tokens[2].indexOf('q') > -1) { + castling.b |= BITS.QSIDE_CASTLE + } + + ep_square = tokens[3] === '-' ? EMPTY : SQUARES[tokens[3]] + half_moves = parseInt(tokens[4], 10) + move_number = parseInt(tokens[5], 10) + + update_setup(generate_fen()) + + return true + } + + /* TODO: this function is pretty much crap - it validates structure but + * completely ignores content (e.g. doesn't verify that each side has a king) + * ... we should rewrite this, and ditch the silly error_number field while + * we're at it + */ + function validate_fen(fen) { + var errors = { + 0: 'No errors.', + 1: 'FEN string must contain six space-delimited fields.', + 2: '6th field (move number) must be a positive integer.', + 3: '5th field (half move counter) must be a non-negative integer.', + 4: '4th field (en-passant square) is invalid.', + 5: '3rd field (castling availability) is invalid.', + 6: '2nd field (side to move) is invalid.', + 7: "1st field (piece positions) does not contain 8 '/'-delimited rows.", + 8: '1st field (piece positions) is invalid [consecutive numbers].', + 9: '1st field (piece positions) is invalid [invalid piece].', + 10: '1st field (piece positions) is invalid [row too large].', + 11: 'Illegal en-passant square' + } + + /* 1st criterion: 6 space-seperated fields? */ + var tokens = fen.split(/\s+/) + if (tokens.length !== 6) { + return { valid: false, error_number: 1, error: errors[1] } + } + + /* 2nd criterion: move number field is a integer value > 0? */ + if (isNaN(tokens[5]) || parseInt(tokens[5], 10) <= 0) { + return { valid: false, error_number: 2, error: errors[2] } + } + + /* 3rd criterion: half move counter is an integer >= 0? */ + if (isNaN(tokens[4]) || parseInt(tokens[4], 10) < 0) { + return { valid: false, error_number: 3, error: errors[3] } + } + + /* 4th criterion: 4th field is a valid e.p.-string? */ + if (!/^(-|[abcdefgh][36])$/.test(tokens[3])) { + return { valid: false, error_number: 4, error: errors[4] } + } + + /* 5th criterion: 3th field is a valid castle-string? */ + if (!/^(KQ?k?q?|Qk?q?|kq?|q|-)$/.test(tokens[2])) { + return { valid: false, error_number: 5, error: errors[5] } + } + + /* 6th criterion: 2nd field is "w" (white) or "b" (black)? */ + if (!/^(w|b)$/.test(tokens[1])) { + return { valid: false, error_number: 6, error: errors[6] } + } + + /* 7th criterion: 1st field contains 8 rows? */ + var rows = tokens[0].split('/') + if (rows.length !== 8) { + return { valid: false, error_number: 7, error: errors[7] } + } + + /* 8th criterion: every row is valid? */ + for (var i = 0; i < rows.length; i++) { + /* check for right sum of fields AND not two numbers in succession */ + var sum_fields = 0 + var previous_was_number = false + + for (var k = 0; k < rows[i].length; k++) { + if (!isNaN(rows[i][k])) { + if (previous_was_number) { + return { valid: false, error_number: 8, error: errors[8] } + } + sum_fields += parseInt(rows[i][k], 10) + previous_was_number = true + } else { + if (!/^[prnbqkPRNBQK]$/.test(rows[i][k])) { + return { valid: false, error_number: 9, error: errors[9] } + } + sum_fields += 1 + previous_was_number = false + } + } + if (sum_fields !== 8) { + return { valid: false, error_number: 10, error: errors[10] } + } + } + + if ( + (tokens[3][1] == '3' && tokens[1] == 'w') || + (tokens[3][1] == '6' && tokens[1] == 'b') + ) { + return { valid: false, error_number: 11, error: errors[11] } + } + + /* everything's okay! */ + return { valid: true, error_number: 0, error: errors[0] } + } + + function generate_fen() { + var empty = 0 + var fen = '' + + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + if (board[i] == null) { + empty++ + } else { + if (empty > 0) { + fen += empty + empty = 0 + } + var color = board[i].color + var piece = board[i].type + + fen += color === WHITE ? piece.toUpperCase() : piece.toLowerCase() + } + + if ((i + 1) & 0x88) { + if (empty > 0) { + fen += empty + } + + if (i !== SQUARES.h1) { + fen += '/' + } + + empty = 0 + i += 8 + } + } + + var cflags = '' + if (castling[WHITE] & BITS.KSIDE_CASTLE) { + cflags += 'K' + } + if (castling[WHITE] & BITS.QSIDE_CASTLE) { + cflags += 'Q' + } + if (castling[BLACK] & BITS.KSIDE_CASTLE) { + cflags += 'k' + } + if (castling[BLACK] & BITS.QSIDE_CASTLE) { + cflags += 'q' + } + + /* do we have an empty castling flag? */ + cflags = cflags || '-' + var epflags = ep_square === EMPTY ? '-' : algebraic(ep_square) + + return [fen, turn, cflags, epflags, half_moves, move_number].join(' ') + } + + function set_header(args) { + for (var i = 0; i < args.length; i += 2) { + if (typeof args[i] === 'string' && typeof args[i + 1] === 'string') { + header[args[i]] = args[i + 1] + } + } + return header + } + + /* called when the initial board setup is changed with put() or remove(). + * modifies the SetUp and FEN properties of the header object. if the FEN is + * equal to the default position, the SetUp and FEN are deleted + * the setup is only updated if history.length is zero, ie moves haven't been + * made. + */ + function update_setup(fen) { + if (history.length > 0) return + + if (fen !== DEFAULT_POSITION) { + header['SetUp'] = '1' + header['FEN'] = fen + } else { + delete header['SetUp'] + delete header['FEN'] + } + } + + function get(square) { + var piece = board[SQUARES[square]] + return piece ? { type: piece.type, color: piece.color } : null + } + + function put(piece, square) { + /* check for valid piece object */ + if (!('type' in piece && 'color' in piece)) { + return false + } + + /* check for piece */ + if (SYMBOLS.indexOf(piece.type.toLowerCase()) === -1) { + return false + } + + /* check for valid square */ + if (!(square in SQUARES)) { + return false + } + + var sq = SQUARES[square] + + /* don't let the user place more than one king */ + if ( + piece.type == KING && + !(kings[piece.color] == EMPTY || kings[piece.color] == sq) + ) { + return false + } + + board[sq] = { type: piece.type, color: piece.color } + if (piece.type === KING) { + kings[piece.color] = sq + } + + update_setup(generate_fen()) + + return true + } + + function remove(square) { + var piece = get(square) + board[SQUARES[square]] = null + if (piece && piece.type === KING) { + kings[piece.color] = EMPTY + } + + update_setup(generate_fen()) + + return piece + } + + function build_move(board, from, to, flags, promotion) { + var move = { + color: turn, + from: from, + to: to, + flags: flags, + piece: board[from].type + } + + if (promotion) { + move.flags |= BITS.PROMOTION + move.promotion = promotion + } + + if (board[to]) { + move.captured = board[to].type + } else if (flags & BITS.EP_CAPTURE) { + move.captured = PAWN + } + return move + } + + function generate_moves(options) { + function add_move(board, moves, from, to, flags) { + /* if pawn promotion */ + if ( + board[from].type === PAWN && + (rank(to) === RANK_8 || rank(to) === RANK_1) + ) { + var pieces = [QUEEN, ROOK, BISHOP, KNIGHT] + for (var i = 0, len = pieces.length; i < len; i++) { + moves.push(build_move(board, from, to, flags, pieces[i])) + } + } else { + moves.push(build_move(board, from, to, flags)) + } + } + + var moves = [] + var us = turn + var them = swap_color(us) + var second_rank = { b: RANK_7, w: RANK_2 } + + var first_sq = SQUARES.a8 + var last_sq = SQUARES.h1 + var single_square = false + + /* do we want legal moves? */ + var legal = + typeof options !== 'undefined' && 'legal' in options + ? options.legal + : true + + /* are we generating moves for a single square? */ + if (typeof options !== 'undefined' && 'square' in options) { + if (options.square in SQUARES) { + first_sq = last_sq = SQUARES[options.square] + single_square = true + } else { + /* invalid square */ + return [] + } + } + + for (var i = first_sq; i <= last_sq; i++) { + /* did we run off the end of the board */ + if (i & 0x88) { + i += 7 + continue + } + + var piece = board[i] + if (piece == null || piece.color !== us) { + continue + } + + if (piece.type === PAWN) { + /* single square, non-capturing */ + var square = i + PAWN_OFFSETS[us][0] + if (board[square] == null) { + add_move(board, moves, i, square, BITS.NORMAL) + + /* double square */ + var square = i + PAWN_OFFSETS[us][1] + if (second_rank[us] === rank(i) && board[square] == null) { + add_move(board, moves, i, square, BITS.BIG_PAWN) + } + } + + /* pawn captures */ + for (j = 2; j < 4; j++) { + var square = i + PAWN_OFFSETS[us][j] + if (square & 0x88) continue + + if (board[square] != null && board[square].color === them) { + add_move(board, moves, i, square, BITS.CAPTURE) + } else if (square === ep_square) { + add_move(board, moves, i, ep_square, BITS.EP_CAPTURE) + } + } + } else { + for (var j = 0, len = PIECE_OFFSETS[piece.type].length; j < len; j++) { + var offset = PIECE_OFFSETS[piece.type][j] + var square = i + + while (true) { + square += offset + if (square & 0x88) break + + if (board[square] == null) { + add_move(board, moves, i, square, BITS.NORMAL) + } else { + if (board[square].color === us) break + add_move(board, moves, i, square, BITS.CAPTURE) + break + } + + /* break, if knight or king */ + if (piece.type === 'n' || piece.type === 'k') break + } + } + } + } + + /* check for castling if: a) we're generating all moves, or b) we're doing + * single square move generation on the king's square + */ + if (!single_square || last_sq === kings[us]) { + /* king-side castling */ + if (castling[us] & BITS.KSIDE_CASTLE) { + var castling_from = kings[us] + var castling_to = castling_from + 2 + + if ( + board[castling_from + 1] == null && + board[castling_to] == null && + !attacked(them, kings[us]) && + !attacked(them, castling_from + 1) && + !attacked(them, castling_to) + ) { + add_move(board, moves, kings[us], castling_to, BITS.KSIDE_CASTLE) + } + } + + /* queen-side castling */ + if (castling[us] & BITS.QSIDE_CASTLE) { + var castling_from = kings[us] + var castling_to = castling_from - 2 + + if ( + board[castling_from - 1] == null && + board[castling_from - 2] == null && + board[castling_from - 3] == null && + !attacked(them, kings[us]) && + !attacked(them, castling_from - 1) && + !attacked(them, castling_to) + ) { + add_move(board, moves, kings[us], castling_to, BITS.QSIDE_CASTLE) + } + } + } + + /* return all pseudo-legal moves (this includes moves that allow the king + * to be captured) + */ + if (!legal) { + return moves + } + + /* filter out illegal moves */ + var legal_moves = [] + for (var i = 0, len = moves.length; i < len; i++) { + make_move(moves[i]) + if (!king_attacked(us)) { + legal_moves.push(moves[i]) + } + undo_move() + } + + return legal_moves + } + + /* convert a move from 0x88 coordinates to Standard Algebraic Notation + * (SAN) + * + * @param {boolean} sloppy Use the sloppy SAN generator to work around over + * disambiguation bugs in Fritz and Chessbase. See below: + * + * r1bqkbnr/ppp2ppp/2n5/1B1pP3/4P3/8/PPPP2PP/RNBQK1NR b KQkq - 2 4 + * 4. ... Nge7 is overly disambiguated because the knight on c6 is pinned + * 4. ... Ne7 is technically the valid SAN + */ + function move_to_san(move, sloppy) { + var output = '' + + if (move.flags & BITS.KSIDE_CASTLE) { + output = 'O-O' + } else if (move.flags & BITS.QSIDE_CASTLE) { + output = 'O-O-O' + } else { + var disambiguator = get_disambiguator(move, sloppy) + + if (move.piece !== PAWN) { + output += move.piece.toUpperCase() + disambiguator + } + + if (move.flags & (BITS.CAPTURE | BITS.EP_CAPTURE)) { + if (move.piece === PAWN) { + output += algebraic(move.from)[0] + } + output += 'x' + } + + output += algebraic(move.to) + + if (move.flags & BITS.PROMOTION) { + output += '=' + move.promotion.toUpperCase() + } + } + + make_move(move) + if (in_check()) { + if (in_checkmate()) { + output += '#' + } else { + output += '+' + } + } + undo_move() + + return output + } + + // parses all of the decorators out of a SAN string + function stripped_san(move) { + return move.replace(/=/, '').replace(/[+#]?[?!]*$/, '') + } + + function attacked(color, square) { + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + /* did we run off the end of the board */ + if (i & 0x88) { + i += 7 + continue + } + + /* if empty square or wrong color */ + if (board[i] == null || board[i].color !== color) continue + + var piece = board[i] + var difference = i - square + var index = difference + 119 + + if (ATTACKS[index] & (1 << SHIFTS[piece.type])) { + if (piece.type === PAWN) { + if (difference > 0) { + if (piece.color === WHITE) return true + } else { + if (piece.color === BLACK) return true + } + continue + } + + /* if the piece is a knight or a king */ + if (piece.type === 'n' || piece.type === 'k') return true + + var offset = RAYS[index] + var j = i + offset + + var blocked = false + while (j !== square) { + if (board[j] != null) { + blocked = true + break + } + j += offset + } + + if (!blocked) return true + } + } + + return false + } + + function king_attacked(color) { + return attacked(swap_color(color), kings[color]) + } + + function in_check() { + return king_attacked(turn) + } + + function in_checkmate() { + return in_check() && generate_moves().length === 0 + } + + function in_stalemate() { + return !in_check() && generate_moves().length === 0 + } + + function insufficient_material() { + var pieces = {} + var bishops = [] + var num_pieces = 0 + var sq_color = 0 + + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + sq_color = (sq_color + 1) % 2 + if (i & 0x88) { + i += 7 + continue + } + + var piece = board[i] + if (piece) { + pieces[piece.type] = piece.type in pieces ? pieces[piece.type] + 1 : 1 + if (piece.type === BISHOP) { + bishops.push(sq_color) + } + num_pieces++ + } + } + + /* k vs. k */ + if (num_pieces === 2) { + return true + } else if ( + /* k vs. kn .... or .... k vs. kb */ + num_pieces === 3 && + (pieces[BISHOP] === 1 || pieces[KNIGHT] === 1) + ) { + return true + } else if (num_pieces === pieces[BISHOP] + 2) { + /* kb vs. kb where any number of bishops are all on the same color */ + var sum = 0 + var len = bishops.length + for (var i = 0; i < len; i++) { + sum += bishops[i] + } + if (sum === 0 || sum === len) { + return true + } + } + + return false + } + + function in_threefold_repetition() { + /* TODO: while this function is fine for casual use, a better + * implementation would use a Zobrist key (instead of FEN). the + * Zobrist key would be maintained in the make_move/undo_move functions, + * avoiding the costly that we do below. + */ + var moves = [] + var positions = {} + var repetition = false + + while (true) { + var move = undo_move() + if (!move) break + moves.push(move) + } + + while (true) { + /* remove the last two fields in the FEN string, they're not needed + * when checking for draw by rep */ + var fen = generate_fen() + .split(' ') + .slice(0, 4) + .join(' ') + + /* has the position occurred three or move times */ + positions[fen] = fen in positions ? positions[fen] + 1 : 1 + if (positions[fen] >= 3) { + repetition = true + } + + if (!moves.length) { + break + } + make_move(moves.pop()) + } + + return repetition + } + + function push(move) { + history.push({ + move: move, + kings: { b: kings.b, w: kings.w }, + turn: turn, + castling: { b: castling.b, w: castling.w }, + ep_square: ep_square, + half_moves: half_moves, + move_number: move_number + }) + } + + function make_move(move) { + var us = turn + var them = swap_color(us) + push(move) + + board[move.to] = board[move.from] + board[move.from] = null + + /* if ep capture, remove the captured pawn */ + if (move.flags & BITS.EP_CAPTURE) { + if (turn === BLACK) { + board[move.to - 16] = null + } else { + board[move.to + 16] = null + } + } + + /* if pawn promotion, replace with new piece */ + if (move.flags & BITS.PROMOTION) { + board[move.to] = { type: move.promotion, color: us } + } + + /* if we moved the king */ + if (board[move.to].type === KING) { + kings[board[move.to].color] = move.to + + /* if we castled, move the rook next to the king */ + if (move.flags & BITS.KSIDE_CASTLE) { + var castling_to = move.to - 1 + var castling_from = move.to + 1 + board[castling_to] = board[castling_from] + board[castling_from] = null + } else if (move.flags & BITS.QSIDE_CASTLE) { + var castling_to = move.to + 1 + var castling_from = move.to - 2 + board[castling_to] = board[castling_from] + board[castling_from] = null + } + + /* turn off castling */ + castling[us] = '' + } + + /* turn off castling if we move a rook */ + if (castling[us]) { + for (var i = 0, len = ROOKS[us].length; i < len; i++) { + if ( + move.from === ROOKS[us][i].square && + castling[us] & ROOKS[us][i].flag + ) { + castling[us] ^= ROOKS[us][i].flag + break + } + } + } + + /* turn off castling if we capture a rook */ + if (castling[them]) { + for (var i = 0, len = ROOKS[them].length; i < len; i++) { + if ( + move.to === ROOKS[them][i].square && + castling[them] & ROOKS[them][i].flag + ) { + castling[them] ^= ROOKS[them][i].flag + break + } + } + } + + /* if big pawn move, update the en passant square */ + if (move.flags & BITS.BIG_PAWN) { + if (turn === 'b') { + ep_square = move.to - 16 + } else { + ep_square = move.to + 16 + } + } else { + ep_square = EMPTY + } + + /* reset the 50 move counter if a pawn is moved or a piece is captured */ + if (move.piece === PAWN) { + half_moves = 0 + } else if (move.flags & (BITS.CAPTURE | BITS.EP_CAPTURE)) { + half_moves = 0 + } else { + half_moves++ + } + + if (turn === BLACK) { + move_number++ + } + turn = swap_color(turn) + } + + function undo_move() { + var old = history.pop() + if (old == null) { + return null + } + + var move = old.move + kings = old.kings + turn = old.turn + castling = old.castling + ep_square = old.ep_square + half_moves = old.half_moves + move_number = old.move_number + + var us = turn + var them = swap_color(turn) + + board[move.from] = board[move.to] + board[move.from].type = move.piece // to undo any promotions + board[move.to] = null + + if (move.flags & BITS.CAPTURE) { + board[move.to] = { type: move.captured, color: them } + } else if (move.flags & BITS.EP_CAPTURE) { + var index + if (us === BLACK) { + index = move.to - 16 + } else { + index = move.to + 16 + } + board[index] = { type: PAWN, color: them } + } + + if (move.flags & (BITS.KSIDE_CASTLE | BITS.QSIDE_CASTLE)) { + var castling_to, castling_from + if (move.flags & BITS.KSIDE_CASTLE) { + castling_to = move.to + 1 + castling_from = move.to - 1 + } else if (move.flags & BITS.QSIDE_CASTLE) { + castling_to = move.to - 2 + castling_from = move.to + 1 + } + + board[castling_to] = board[castling_from] + board[castling_from] = null + } + + return move + } + + /* this function is used to uniquely identify ambiguous moves */ + function get_disambiguator(move, sloppy) { + var moves = generate_moves({ legal: !sloppy }) + + var from = move.from + var to = move.to + var piece = move.piece + + var ambiguities = 0 + var same_rank = 0 + var same_file = 0 + + for (var i = 0, len = moves.length; i < len; i++) { + var ambig_from = moves[i].from + var ambig_to = moves[i].to + var ambig_piece = moves[i].piece + + /* if a move of the same piece type ends on the same to square, we'll + * need to add a disambiguator to the algebraic notation + */ + if (piece === ambig_piece && from !== ambig_from && to === ambig_to) { + ambiguities++ + + if (rank(from) === rank(ambig_from)) { + same_rank++ + } + + if (file(from) === file(ambig_from)) { + same_file++ + } + } + } + + if (ambiguities > 0) { + /* if there exists a similar moving piece on the same rank and file as + * the move in question, use the square as the disambiguator + */ + if (same_rank > 0 && same_file > 0) { + return algebraic(from) + } else if (same_file > 0) { + /* if the moving piece rests on the same file, use the rank symbol as the + * disambiguator + */ + return algebraic(from).charAt(1) + } else { + /* else use the file symbol */ + return algebraic(from).charAt(0) + } + } + + return '' + } + + function ascii() { + var s = ' +------------------------+\n' + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + /* display the rank */ + if (file(i) === 0) { + s += ' ' + '87654321'[rank(i)] + ' |' + } + + /* empty piece */ + if (board[i] == null) { + s += ' . ' + } else { + var piece = board[i].type + var color = board[i].color + var symbol = color === WHITE ? piece.toUpperCase() : piece.toLowerCase() + s += ' ' + symbol + ' ' + } + + if ((i + 1) & 0x88) { + s += '|\n' + i += 8 + } + } + s += ' +------------------------+\n' + s += ' a b c d e f g h\n' + + return s + } + + // convert a move from Standard Algebraic Notation (SAN) to 0x88 coordinates + function move_from_san(move, sloppy) { + // strip off any move decorations: e.g Nf3+?! + var clean_move = stripped_san(move) + + // if we're using the sloppy parser run a regex to grab piece, to, and from + // this should parse invalid SAN like: Pe2-e4, Rc1c4, Qf3xf7 + if (sloppy) { + var matches = clean_move.match( + /([pnbrqkPNBRQK])?([a-h][1-8])x?-?([a-h][1-8])([qrbnQRBN])?/ + ) + if (matches) { + var piece = matches[1] + var from = matches[2] + var to = matches[3] + var promotion = matches[4] + } + } + + var moves = generate_moves() + for (var i = 0, len = moves.length; i < len; i++) { + // try the strict parser first, then the sloppy parser if requested + // by the user + if ( + clean_move === stripped_san(move_to_san(moves[i])) || + (sloppy && clean_move === stripped_san(move_to_san(moves[i], true))) + ) { + return moves[i] + } else { + if ( + matches && + (!piece || piece.toLowerCase() == moves[i].piece) && + SQUARES[from] == moves[i].from && + SQUARES[to] == moves[i].to && + (!promotion || promotion.toLowerCase() == moves[i].promotion) + ) { + return moves[i] + } + } + } + + return null + } + + /***************************************************************************** + * UTILITY FUNCTIONS + ****************************************************************************/ + function rank(i) { + return i >> 4 + } + + function file(i) { + return i & 15 + } + + function algebraic(i) { + var f = file(i), + r = rank(i) + return 'abcdefgh'.substring(f, f + 1) + '87654321'.substring(r, r + 1) + } + + function swap_color(c) { + return c === WHITE ? BLACK : WHITE + } + + function is_digit(c) { + return '0123456789'.indexOf(c) !== -1 + } + + /* pretty = external move object */ + function make_pretty(ugly_move) { + var move = clone(ugly_move) + move.san = move_to_san(move, false) + move.to = algebraic(move.to) + move.from = algebraic(move.from) + + var flags = '' + + for (var flag in BITS) { + if (BITS[flag] & move.flags) { + flags += FLAGS[flag] + } + } + move.flags = flags + + return move + } + + function clone(obj) { + var dupe = obj instanceof Array ? [] : {} + + for (var property in obj) { + if (typeof property === 'object') { + dupe[property] = clone(obj[property]) + } else { + dupe[property] = obj[property] + } + } + + return dupe + } + + function trim(str) { + return str.replace(/^\s+|\s+$/g, '') + } + + /***************************************************************************** + * DEBUGGING UTILITIES + ****************************************************************************/ + function perft(depth) { + var moves = generate_moves({ legal: false }) + var nodes = 0 + var color = turn + + for (var i = 0, len = moves.length; i < len; i++) { + make_move(moves[i]) + if (!king_attacked(color)) { + if (depth - 1 > 0) { + var child_nodes = perft(depth - 1) + nodes += child_nodes + } else { + nodes++ + } + } + undo_move() + } + + return nodes + } + + return { + /*************************************************************************** + * PUBLIC CONSTANTS (is there a better way to do this?) + **************************************************************************/ + WHITE: WHITE, + BLACK: BLACK, + PAWN: PAWN, + KNIGHT: KNIGHT, + BISHOP: BISHOP, + ROOK: ROOK, + QUEEN: QUEEN, + KING: KING, + SQUARES: (function() { + /* from the ECMA-262 spec (section 12.6.4): + * "The mechanics of enumerating the properties ... is + * implementation dependent" + * so: for (var sq in SQUARES) { keys.push(sq); } might not be + * ordered correctly + */ + var keys = [] + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + if (i & 0x88) { + i += 7 + continue + } + keys.push(algebraic(i)) + } + return keys + })(), + FLAGS: FLAGS, + + /*************************************************************************** + * PUBLIC API + **************************************************************************/ + load: function(fen) { + return load(fen) + }, + + reset: function() { + return reset() + }, + + moves: function(options) { + /* The internal representation of a chess move is in 0x88 format, and + * not meant to be human-readable. The code below converts the 0x88 + * square coordinates to algebraic coordinates. It also prunes an + * unnecessary move keys resulting from a verbose call. + */ + + var ugly_moves = generate_moves(options) + var moves = [] + + for (var i = 0, len = ugly_moves.length; i < len; i++) { + /* does the user want a full move object (most likely not), or just + * SAN + */ + if ( + typeof options !== 'undefined' && + 'verbose' in options && + options.verbose + ) { + moves.push(make_pretty(ugly_moves[i])) + } else { + moves.push(move_to_san(ugly_moves[i], false)) + } + } + + return moves + }, + + in_check: function() { + return in_check() + }, + + in_checkmate: function() { + return in_checkmate() + }, + + in_stalemate: function() { + return in_stalemate() + }, + + in_draw: function() { + return ( + half_moves >= 100 || + in_stalemate() || + insufficient_material() || + in_threefold_repetition() + ) + }, + + insufficient_material: function() { + return insufficient_material() + }, + + in_threefold_repetition: function() { + return in_threefold_repetition() + }, + + game_over: function() { + return ( + half_moves >= 100 || + in_checkmate() || + in_stalemate() || + insufficient_material() || + in_threefold_repetition() + ) + }, + + validate_fen: function(fen) { + return validate_fen(fen) + }, + + fen: function() { + return generate_fen() + }, + + board: function() { + var output = [], + row = [] + + for (var i = SQUARES.a8; i <= SQUARES.h1; i++) { + if (board[i] == null) { + row.push(null) + } else { + row.push({ type: board[i].type, color: board[i].color }) + } + if ((i + 1) & 0x88) { + output.push(row) + row = [] + i += 8 + } + } + + return output + }, + + pgn: function(options) { + /* using the specification from http://www.chessclub.com/help/PGN-spec + * example for html usage: .pgn({ max_width: 72, newline_char: "
" }) + */ + var newline = + typeof options === 'object' && typeof options.newline_char === 'string' + ? options.newline_char + : '\n' + var max_width = + typeof options === 'object' && typeof options.max_width === 'number' + ? options.max_width + : 0 + var result = [] + var header_exists = false + + /* add the PGN header headerrmation */ + for (var i in header) { + /* TODO: order of enumerated properties in header object is not + * guaranteed, see ECMA-262 spec (section 12.6.4) + */ + result.push('[' + i + ' "' + header[i] + '"]' + newline) + header_exists = true + } + + if (header_exists && history.length) { + result.push(newline) + } + + /* pop all of history onto reversed_history */ + var reversed_history = [] + while (history.length > 0) { + reversed_history.push(undo_move()) + } + + var moves = [] + var move_string = '' + + /* build the list of moves. a move_string looks like: "3. e3 e6" */ + while (reversed_history.length > 0) { + var move = reversed_history.pop() + + /* if the position started with black to move, start PGN with 1. ... */ + if (!history.length && move.color === 'b') { + move_string = move_number + '. ...' + } else if (move.color === 'w') { + /* store the previous generated move_string if we have one */ + if (move_string.length) { + moves.push(move_string) + } + move_string = move_number + '.' + } + + move_string = move_string + ' ' + move_to_san(move, false) + make_move(move) + } + + /* are there any other leftover moves? */ + if (move_string.length) { + moves.push(move_string) + } + + /* is there a result? */ + if (typeof header.Result !== 'undefined') { + moves.push(header.Result) + } + + /* history should be back to what is was before we started generating PGN, + * so join together moves + */ + if (max_width === 0) { + return result.join('') + moves.join(' ') + } + + /* wrap the PGN output at max_width */ + var current_width = 0 + for (var i = 0; i < moves.length; i++) { + /* if the current move will push past max_width */ + if (current_width + moves[i].length > max_width && i !== 0) { + /* don't end the line with whitespace */ + if (result[result.length - 1] === ' ') { + result.pop() + } + + result.push(newline) + current_width = 0 + } else if (i !== 0) { + result.push(' ') + current_width++ + } + result.push(moves[i]) + current_width += moves[i].length + } + + return result.join('') + }, + + load_pgn: function(pgn, options) { + // allow the user to specify the sloppy move parser to work around over + // disambiguation bugs in Fritz and Chessbase + var sloppy = + typeof options !== 'undefined' && 'sloppy' in options + ? options.sloppy + : false + + function mask(str) { + return str.replace(/\\/g, '\\') + } + + function has_keys(object) { + for (var key in object) { + return true + } + return false + } + + function parse_pgn_header(header, options) { + var newline_char = + typeof options === 'object' && + typeof options.newline_char === 'string' + ? options.newline_char + : '\r?\n' + var header_obj = {} + var headers = header.split(new RegExp(mask(newline_char))) + var key = '' + var value = '' + + for (var i = 0; i < headers.length; i++) { + key = headers[i].replace(/^\[([A-Z][A-Za-z]*)\s.*\]$/, '$1') + value = headers[i].replace(/^\[[A-Za-z]+\s"(.*)"\]$/, '$1') + if (trim(key).length > 0) { + header_obj[key] = value + } + } + + return header_obj + } + + var newline_char = + typeof options === 'object' && typeof options.newline_char === 'string' + ? options.newline_char + : '\r?\n' + + // RegExp to split header. Takes advantage of the fact that header and movetext + // will always have a blank line between them (ie, two newline_char's). + // With default newline_char, will equal: /^(\[((?:\r?\n)|.)*\])(?:\r?\n){2}/ + var header_regex = new RegExp( + '^(\\[((?:' + + mask(newline_char) + + ')|.)*\\])' + + '(?:' + + mask(newline_char) + + '){2}' + ) + + // If no header given, begin with moves. + var header_string = header_regex.test(pgn) + ? header_regex.exec(pgn)[1] + : '' + + // Put the board in the starting position + reset() + + /* parse PGN header */ + var headers = parse_pgn_header(header_string, options) + for (var key in headers) { + set_header([key, headers[key]]) + } + + /* load the starting position indicated by [Setup '1'] and + * [FEN position] */ + if (headers['SetUp'] === '1') { + if (!('FEN' in headers && load(headers['FEN'], true))) { + // second argument to load: don't clear the headers + return false + } + } + + /* delete header to get the moves */ + var ms = pgn + .replace(header_string, '') + .replace(new RegExp(mask(newline_char), 'g'), ' ') + + /* delete comments */ + ms = ms.replace(/(\{[^}]+\})+?/g, '') + + /* delete recursive annotation variations */ + var rav_regex = /(\([^\(\)]+\))+?/g + while (rav_regex.test(ms)) { + ms = ms.replace(rav_regex, '') + } + + /* delete move numbers */ + ms = ms.replace(/\d+\.(\.\.)?/g, '') + + /* delete ... indicating black to move */ + ms = ms.replace(/\.\.\./g, '') + + /* delete numeric annotation glyphs */ + ms = ms.replace(/\$\d+/g, '') + + /* trim and get array of moves */ + var moves = trim(ms).split(new RegExp(/\s+/)) + + /* delete empty entries */ + moves = moves + .join(',') + .replace(/,,+/g, ',') + .split(',') + var move = '' + + for (var half_move = 0; half_move < moves.length - 1; half_move++) { + move = move_from_san(moves[half_move], sloppy) + + /* move not possible! (don't clear the board to examine to show the + * latest valid position) + */ + if (move == null) { + return false + } else { + make_move(move) + } + } + + /* examine last move */ + move = moves[moves.length - 1] + if (POSSIBLE_RESULTS.indexOf(move) > -1) { + if (has_keys(header) && typeof header.Result === 'undefined') { + set_header(['Result', move]) + } + } else { + move = move_from_san(move, sloppy) + if (move == null) { + return false + } else { + make_move(move) + } + } + return true + }, + + header: function() { + return set_header(arguments) + }, + + ascii: function() { + return ascii() + }, + + turn: function() { + return turn + }, + + move: function(move, options) { + /* The move function can be called with in the following parameters: + * + * .move('Nxb7') <- where 'move' is a case-sensitive SAN string + * + * .move({ from: 'h7', <- where the 'move' is a move object (additional + * to :'h8', fields are ignored) + * promotion: 'q', + * }) + */ + + // allow the user to specify the sloppy move parser to work around over + // disambiguation bugs in Fritz and Chessbase + var sloppy = + typeof options !== 'undefined' && 'sloppy' in options + ? options.sloppy + : false + + var move_obj = null + + if (typeof move === 'string') { + move_obj = move_from_san(move, sloppy) + } else if (typeof move === 'object') { + var moves = generate_moves() + + /* convert the pretty move object to an ugly move object */ + for (var i = 0, len = moves.length; i < len; i++) { + if ( + move.from === algebraic(moves[i].from) && + move.to === algebraic(moves[i].to) && + (!('promotion' in moves[i]) || + move.promotion === moves[i].promotion) + ) { + move_obj = moves[i] + break + } + } + } + + /* failed to find move */ + if (!move_obj) { + return null + } + + /* need to make a copy of move because we can't generate SAN after the + * move is made + */ + var pretty_move = make_pretty(move_obj) + + make_move(move_obj) + + return pretty_move + }, + + undo: function() { + var move = undo_move() + return move ? make_pretty(move) : null + }, + + clear: function() { + return clear() + }, + + put: function(piece, square) { + return put(piece, square) + }, + + get: function(square) { + return get(square) + }, + + remove: function(square) { + return remove(square) + }, + + perft: function(depth) { + return perft(depth) + }, + + square_color: function(square) { + if (square in SQUARES) { + var sq_0x88 = SQUARES[square] + return (rank(sq_0x88) + file(sq_0x88)) % 2 === 0 ? 'light' : 'dark' + } + + return null + }, + + history: function(options) { + var reversed_history = [] + var move_history = [] + var verbose = + typeof options !== 'undefined' && + 'verbose' in options && + options.verbose + + while (history.length > 0) { + reversed_history.push(undo_move()) + } + + while (reversed_history.length > 0) { + var move = reversed_history.pop() + if (verbose) { + move_history.push(make_pretty(move)) + } else { + move_history.push(move_to_san(move)) + } + make_move(move) + } + + return move_history + } + } +} + +/* export Chess object if using node or any other CommonJS compatible + * environment */ +if (typeof exports !== 'undefined') exports.Chess = Chess +/* export Chess object for any RequireJS compatible environment */ +if (typeof define !== 'undefined') + define(function() { + return Chess + })