Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,37 @@

[![Play free on GitHub Pages](https://img.shields.io/badge/Play%20free-on%20GitHub%20Pages-d4a15a?style=for-the-badge&labelColor=14110f)](https://wsams.github.io/acnab/web/)

**acnab** renders chess positions from standard chess notation in the terminal and in the browser.
**Play chess right on GitHub. Take lessons free.** No install, no account, no ads, no tracking.

Open **[wsams.github.io/acnab/web](https://wsams.github.io/acnab/web/)** and the board is already there. Type SAN/PGN, or click pieces. Two players can each turn on a coach before the first move, and both of them see that choice. The page is static: notation, saves, the CPU, and the coach all stay in your browser.

**Play it free right now:** [wsams.github.io/acnab/web](https://wsams.github.io/acnab/web/) — no install, no account. Type SAN/PGN moves and watch the board update live. Optional **CPU player** (Stockfish in the browser) can be toggled on for a real game after a coin toss for White.
**acnab** renders chess positions from standard chess notation in the terminal and in the browser.

![acnab web UI — Midnight theme with Blush & Magenta pieces, dual clocks, live notation, and local saves](docs/web-ui.png)

The browser UI shows the live board beside a Moves panel: type SAN/PGN, hit **Render**, and the position updates with FEN, material, and move history. Dual clocks, theme/piece-color presets, and browser-local saves sit alongside the board for over-the-board notation tracking. Turn on **CPU player** to face Stockfish at a chosen strength. Type SAN, or use **Click to move**: click a piece to lift it, click a square to place it, and the notation fills in with the move.
The browser UI shows the live board beside a Moves panel: type SAN/PGN, hit **Render**, and the position updates with FEN, material, and move history. Dual clocks, theme/piece-color presets, and browser-local saves sit alongside the board. Turn on **CPU player** to face Stockfish at a chosen strength. **Lessons** is a second engine that never moves a piece: before the first move, each player chooses a coach, both players see who has one, and the choice locks for that game. It explains candidate moves in chess language and lets you preview a line before you commit. Type SAN, or use **Click to move**.

## Play free, and take a lesson

The whole app is published on GitHub Pages from this repository. You do not download a client, create an account, or sit through an ad.

- **Play:** [https://wsams.github.io/acnab/web/](https://wsams.github.io/acnab/web/)
- **Lessons:** on a new board, each side turns a coach on before the first move. Both players see the choice, and it cannot be turned on once the game has started. Against the CPU, choose your own coach before the match starts.
- **Privacy:** acnab does not include analytics, accounts, or ads. Games and the lesson setting are stored in your browser only. Stockfish is vendored under `web/vendor/stockfish/` (GPLv3) and runs locally.

### What the coach does

Lessons are a second Stockfish. With two players at the board, each side can take a coach. The buttons stay visible, so you always know when the other player is using one. That choice is made before the first move. Against the CPU, you choose your own coach before the match starts. The coach does not play a move.

- It offers a few tries, scored from White’s side of the evaluation, and says whether each one is the first choice, a sound alternative, or a concession.
- The write-up uses real chess ideas it can actually see on the board: development, the center, king safety, absolute and relative pins, forks, discovered check, skewers, outposts, passed pawns, pawn breaks, open files, prophylaxis, and opposition. A Ruy Lopez bishop is described as pressure, not a pin, while the d-pawn still blocks the king.
- **Preview line** steps through the coach’s continuation on the board. **Watch** plays that line forward. Nothing is written into the notation.
- **Play** commits just that one move, the same way a click or a typed SAN would. You can ignore the suggestions and move however you like.
- With two players, tries appear only on the turn of a side that chose a coach. The other player still sees that the coach is on.
- After a coached move, the coach says whether it was the move it wanted. After the other side replies, it compares that reply with the line it expected, then reads the new position.
- **Glance**, **Study**, and **Master class** change how long the coach thinks. Study is the default.

The CPU opponent is separate. Its strength is still the level you pick. The coach stays full-strength so a weaker CPU can be compared with the try a stronger player would choose.

## CLI usage

Expand Down Expand Up @@ -48,7 +72,7 @@ python3 chess.py --list-games

### Free on GitHub Pages

Open **[https://wsams.github.io/acnab/web/](https://wsams.github.io/acnab/web/)** to play in the browser for free.
Open **[https://wsams.github.io/acnab/web/](https://wsams.github.io/acnab/web/)** to play in the browser for free. On a new board, turn a coach on before the first move. No account, no ads, no tracking.

GitHub Pages publishes the static UI from `web/` (the site root redirects there). The build is fully static and client-side:

Expand All @@ -60,7 +84,8 @@ GitHub Pages publishes the static UI from `web/` (the site root redirects there)
- play controls to step through or autoplay a game with a gliding piece animation
- click to lift a piece and click to place it; the moves box fills in SAN as each move lands
- optional CPU opponent (Stockfish.js 18 lite WASM) — off by default; coin toss for who plays White. Limited levels use Stockfish’s UCI Elo scale (1320–2500). Beginner and Casual are approximate mixes below that floor.
- no CDN runtime dependency for the chess engine (Stockfish is vendored under `web/vendor/stockfish/`, GPLv3)
- optional **Lessons** coach — off by default; a second, full-strength Stockfish that never moves. Two players each choose a coach before the first move, and both can see who has one. It cannot be turned on in the middle of a game. It explains candidate moves (pins, forks, outposts, pawn breaks, opposition, and the opening you just entered), lets you preview or watch the line, and only writes a move if you press Play.
- no CDN runtime dependency for the chess engine (Stockfish is vendored under `web/vendor/stockfish/`, GPLv3). The page does not load analytics or ads.

Example shared game (opens at move 1 and plays through to mate):
[Open the demo board](https://wsams.github.io/acnab/web/#g=MS4gZTQgYzUgMi4gTmYzIGQ2IDMuIGQ0IGN4ZDQgNC4gTnhkNCBiNSA1LiBCeGI1KyBCZDcgNi4gTmMzIGY1IDcuIGV4ZjUgZzYgOC4gUWYzIGd4ZjUgOS4gUWg1Iw)
Expand Down
220 changes: 220 additions & 0 deletions tests/coach.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
annotateMove,
buildCandidates,
describeGap,
evalCaption,
formatWhiteEval,
identifyOpening,
moverGap,
narrateDecision,
parseInfoLine,
rankLabelFor,
selectMultipv,
} from '../web/coach.js';
import { renderLine } from '../web/engine.js';

const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';

test('opening names follow the move list and stop at the longest real prefix', () => {
assert.equal(identifyOpening(['e4']), "King's Pawn Opening");
assert.equal(identifyOpening(['e4', 'c5']), 'Sicilian Defense');
assert.equal(identifyOpening(['e4', 'e5', 'Nf3', 'Nc6', 'Bb5']), 'Ruy Lopez');
assert.equal(identifyOpening(['e4', 'e5', 'Nf3', 'Nc6', 'Bb5', 'a6']), 'Ruy Lopez, Morphy Defense');
assert.equal(identifyOpening(['d4', 'd5', 'c4']), "Queen's Gambit");
assert.equal(identifyOpening(['h3']), null);
});

test('eval is shown from White’s side, including mate scores', () => {
assert.equal(formatWhiteEval({ type: 'cp', value: 32 }, 'white'), '+0.32');
assert.equal(formatWhiteEval({ type: 'cp', value: 32 }, 'black'), '-0.32');
assert.equal(formatWhiteEval({ type: 'mate', value: 2 }, 'white'), '+M2');
assert.equal(formatWhiteEval({ type: 'mate', value: 2 }, 'black'), '-M2');
assert.equal(evalCaption({ type: 'cp', value: 32 }, 'white'), 'White is slightly better.');
assert.equal(evalCaption({ type: 'cp', value: 32 }, 'black'), 'Black is slightly better.');
assert.equal(evalCaption({ type: 'mate', value: 3 }, 'white'), 'White forces mate in 3.');
});

test('candidate gaps use chess consequences, not a vague score dump', () => {
assert.equal(moverGap({ type: 'cp', value: 40 }, { type: 'cp', value: 40 }).kind, 'best');
assert.equal(moverGap({ type: 'cp', value: 10 }, { type: 'cp', value: 40 }).loss, 30);
assert.equal(describeGap({ type: 'cp', value: 10 }, { type: 'cp', value: 40 }), 'A small concession, about 0.30 pawns.');
assert.equal(moverGap({ type: 'cp', value: 80 }, { type: 'mate', value: 3 }).kind, 'missed-mate');
assert.equal(describeGap({ type: 'cp', value: 80 }, { type: 'mate', value: 3 }), 'This misses a forced mate.');
assert.equal(rankLabelFor(1, { kind: 'best', loss: 0 }), 'First choice');
assert.equal(rankLabelFor(2, { kind: 'cp', loss: 30 }), 'Alternative');
assert.equal(rankLabelFor(3, { kind: 'cp', loss: 180 }), 'Inaccuracy');
});

test('Stockfish info lines become principal variations, ignoring bounds', () => {
const line = parseInfoLine('info depth 12 seldepth 18 multipv 2 score cp -15 nodes 10 nps 100 time 4 pv e7e5 g1f3');
assert.equal(line.depth, 12);
assert.equal(line.multipv, 2);
assert.deepEqual(line.score, { type: 'cp', value: -15 });
assert.deepEqual(line.pv, ['e7e5', 'g1f3']);
assert.equal(parseInfoLine('info depth 8 multipv 1 score cp 10 upperbound nodes 1 pv e2e4'), null);
assert.equal(parseInfoLine('info string NNUE evaluation using nn-file'), null);

const depths = new Map([
[10, new Map([[1, { depth: 10, multipv: 1, pv: ['e2e4'] }]])],
[12, new Map([
[2, { depth: 12, multipv: 2, pv: ['d2d4'] }],
[1, { depth: 12, multipv: 1, pv: ['e2e4'] }],
])],
[13, new Map([[2, { depth: 13, multipv: 2, pv: ['c2c4'] }]])],
]);
const chosen = selectMultipv(depths);
assert.equal(chosen[0].depth, 12);
assert.deepEqual(chosen.map((entry) => entry.multipv), [1, 2]);
});

test('e4 is taught as a central pawn and the King’s Pawn Opening', () => {
const note = annotateMove(START, 'e4', { gameSans: [], rank: 1, score: { type: 'cp', value: 20 }, bestScore: { type: 'cp', value: 20 } });
assert.equal(note.san, 'e4');
assert.ok(note.themes.includes('center'));
assert.equal(note.opening, "King's Pawn Opening");
assert.match(note.paragraphs.join(' '), /center/i);
assert.match(note.paragraphs.join(' '), /light-squared bishop/);
});

test('Bb5 in the Ruy is pressure, not an absolute pin, while the d-pawn blocks', () => {
const fen = 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3';
const note = annotateMove(fen, 'Bb5', { gameSans: ['e4', 'e5', 'Nf3', 'Nc6'] });
assert.equal(note.opening, 'Ruy Lopez');
assert.equal(note.headline, 'Ruy Lopez');
assert.ok(note.themes.includes('development'));
assert.ok(note.themes.includes('xray'));
assert.equal(note.themes.includes('pin'), false);
const text = note.paragraphs.join(' ');
assert.match(text, /not yet a pin/i);
assert.match(text, /d7/);
});

test('an absolute pin is named only when the king really stands behind the piece', () => {
const fen = 'r1bqkbnr/ppp2ppp/2np4/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 0 4';
const note = annotateMove(fen, 'Bb5');
assert.ok(note.themes.includes('pin'));
assert.match(note.headline, /pin/i);
assert.match(note.paragraphs.join(' '), /Absolute pin/);
assert.match(note.paragraphs.join(' '), /c6/);
});

test('a knight fork is called a fork and a check', () => {
const fen = 'r3k3/8/8/3N4/8/8/8/4K3 w - - 0 1';
const note = annotateMove(fen, 'Nc7');
assert.equal(note.san, 'Nc7+');
assert.ok(note.themes.includes('fork'));
assert.ok(note.themes.includes('check'));
assert.match(note.headline, /fork/i);
assert.match(note.paragraphs.join(' '), /king/i);
assert.match(note.paragraphs.join(' '), /a8/);
});

test('discovered check names the piece that was unmasked', () => {
const fen = '7k/8/8/8/8/8/1N6/B3K3 w - - 0 1';
const note = annotateMove(fen, 'Nc4');
assert.ok(note.themes.includes('discovered-check'));
assert.match(note.headline, /Discovered check/);
assert.match(note.paragraphs.join(' '), /bishop on a1/i);
});

test('castling, pawn breaks, outposts, passed pawns, and open files use their names', () => {
const castle = annotateMove(
'rnbqk2r/pppp1ppp/5n2/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4',
'O-O',
);
assert.ok(castle.themes.includes('castle-kingside'));
assert.match(castle.paragraphs.join(' '), /pawn shield/);

const breakMove = annotateMove(
'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1',
'c5',
);
assert.ok(breakMove.themes.includes('pawn-break'));
assert.match(breakMove.paragraphs.join(' '), /d4/);

const outpost = annotateMove('4k3/8/8/8/2P5/2N5/8/4K3 w - - 0 1', 'Nd5');
assert.ok(outpost.themes.includes('outpost'));
assert.match(outpost.headline, /d5/);

const passed = annotateMove('4k3/8/4P3/8/8/8/8/4K3 w - - 0 1', 'e7');
assert.ok(passed.themes.includes('passed-pawn'));

const file = annotateMove('4k3/8/8/8/8/8/8/R3K3 w - - 0 1', 'Rc1');
assert.ok(file.themes.includes('open-file'));
assert.match(file.headline, /c-file/);
});

test('opposition and prophylaxis are recognized from the geometry', () => {
const opposition = annotateMove('8/8/8/4k3/8/8/4K3/8 w - - 0 1', 'Ke3');
assert.ok(opposition.themes.includes('opposition'));
assert.match(opposition.paragraphs.join(' '), /opposition/i);

const prophylaxis = annotateMove(
'rnbqkbnr/pp3ppp/8/2pp4/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 0 4',
'h3',
);
assert.ok(prophylaxis.themes.includes('prophylaxis'));
assert.match(prophylaxis.paragraphs.join(' '), /g4/);
assert.match(prophylaxis.paragraphs.join(' '), /f3/);
});

test('1.e4 Nf6 is Alekhine, and 2.e5 is called an attack on that knight', () => {
const alekhine = annotateMove(
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1',
'Nf6',
{ gameSans: ['e4'] },
);
assert.equal(alekhine.opening, "Alekhine's Defense");
assert.equal(alekhine.headline, "Alekhine's Defense");

const chase = annotateMove(
'rnbqkb1r/pppppppp/5n2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2',
'e5',
);
assert.ok(chase.themes.includes('tempo'));
assert.match(chase.headline, /knight/i);
assert.match(chase.paragraphs.join(' '), /f6/);
});

test('a principal variation becomes SAN options with a continuation', () => {
const candidates = buildCandidates(START, [
{ depth: 12, multipv: 1, score: { type: 'cp', value: 30 }, pv: ['e2e4', 'e7e5', 'g1f3'] },
{ depth: 12, multipv: 2, score: { type: 'cp', value: -5 }, pv: ['d2d4', 'd7d5', 'c2c4'] },
]);
assert.equal(candidates.length, 2);
assert.equal(candidates[0].san, 'e4');
assert.equal(candidates[0].rankLabel, 'First choice');
assert.match(candidates[0].continuation, /e5 Nf3/);
assert.equal(candidates[1].san, 'd4');
assert.equal(candidates[1].rankLabel, 'Alternative');
assert.equal(candidates[1].plies[0].from, 'd2');
});

test('the coach compares a played move with the tries it already had', () => {
const candidates = buildCandidates(
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1',
[
{ depth: 10, multipv: 1, score: { type: 'cp', value: 28 }, pv: ['c7c5'] },
{ depth: 10, multipv: 2, score: { type: 'cp', value: 12 }, pv: ['e7e5'] },
],
);
const best = narrateDecision({ actor: 'The CPU', playedSan: 'c5', candidates });
assert.match(best.body, /first choice/);
const other = narrateDecision({ actor: 'You', playedSan: 'e5', candidates });
assert.match(other.title, /e5/);
assert.match(other.body, /c5/);
const outside = narrateDecision({ actor: 'The CPU', playedSan: 'a5', candidates });
assert.match(outside.body, /outside the coach's main tries/);
});

test('a preview line can be rendered from a FEN without rewriting the game', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1';
const line = renderLine(fen, ['c5', 'Nf3']);
assert.deepEqual(line.appliedMoves, ['c5', 'Nf3']);
assert.equal(line.history.at(-1).to, 'f3');
assert.equal(line.turn, 'black');
assert.equal(line.board.length, 8);
});
112 changes: 112 additions & 0 deletions tests/lessons-agreement.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
advanceCoaching,
agreementText,
coachAdvises,
liveEnrollment,
} from '../web/lessons.js';

const setup = { phase: 'setup', enrolled: { white: false, black: false }, sans: [] };

test('the first move freezes each side’s coach', () => {
const playing = advanceCoaching(setup, {
live: true,
offerCoaching: true,
sans: ['e4'],
}, { white: true, black: false });
assert.equal(playing.phase, 'playing');
assert.deepEqual(playing.enrolled, { white: true, black: false });

const next = advanceCoaching(playing, {
live: true,
offerCoaching: true,
sans: ['e4', 'e5'],
}, { white: true, black: true });
assert.equal(next.phase, 'playing');
assert.deepEqual(next.enrolled, { white: true, black: false });
});

test('a pasted game does not inherit a coach, and a fresh board does', () => {
const loaded = advanceCoaching(setup, {
live: true,
offerCoaching: true,
sans: ['e4', 'e5', 'Nf3'],
}, { white: true, black: true });
assert.equal(loaded.phase, 'studying');
assert.deepEqual(loaded.enrolled, { white: false, black: false });

const again = advanceCoaching(loaded, {
live: true,
offerCoaching: true,
sans: [],
}, { white: true, black: false });
assert.equal(again.phase, 'setup');
});

test('opening a board that already has moves stays locked', () => {
const booting = advanceCoaching(setup, {
live: true,
offerCoaching: false,
sans: [],
}, { white: true, black: true });
assert.equal(booting.phase, 'studying');

const game = advanceCoaching(booting, {
live: true,
offerCoaching: false,
sans: ['e4'],
}, { white: true, black: true });
assert.equal(game.phase, 'studying');
assert.deepEqual(game.enrolled, { white: false, black: false });
});

test('stepping through history does not change the agreement', () => {
const playing = advanceCoaching(setup, {
live: true,
offerCoaching: true,
sans: ['e4'],
}, { white: false, black: true });
const reviewing = advanceCoaching(playing, {
live: false,
offerCoaching: true,
sans: ['e4'],
}, { white: true, black: true });
assert.equal(reviewing.phase, 'playing');
assert.deepEqual(reviewing.enrolled, { white: false, black: true });
});

test('two players are told who has a coach', () => {
const names = { white: 'Ivory', black: 'Ebony' };
assert.match(agreementText({
phase: 'setup',
enrolled: { white: false, black: false },
sideNames: names,
}), /Before the first move/);
assert.match(agreementText({
phase: 'playing',
enrolled: { white: true, black: false },
sideNames: names,
}), /Ivory is using the coach\. Ebony is not/);
assert.match(agreementText({
phase: 'playing',
enrolled: { white: true, black: true },
sideNames: names,
}), /both using the coach/);
});

test('advice follows the side that chose a coach', () => {
const both = { white: true, black: false };
assert.equal(coachAdvises(both, { turn: 'white' }), true);
assert.equal(coachAdvises(both, { turn: 'black' }), false);
assert.equal(coachAdvises(both, { turn: 'white', isGameOver: true }), false);
assert.deepEqual(
liveEnrollment({ cpu: { enabled: true, humanSide: 'black' } }, { white: true, black: true }, true),
{ white: false, black: true },
);
assert.equal(coachAdvises(
{ white: false, black: true },
{ turn: 'white', cpu: { enabled: true, humanSide: 'black' } },
), true);
});
Loading
Loading