From d48a28fad6633c694586bcde6b23aecace2a6524d618763a0037fdb759974906 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 19 Oct 2026 16:03:11 -0400 Subject: [PATCH] v0.14.0 web replay UI, My games, CLI replay --- .github/workflows/test.yml | 52 + Makefile | 7 + README.md | 20 +- cmd/chess-client-cli/main.go | 6 + doc/api.md | 10 +- doc/client.md | 85 +- doc/development.md | 6 +- doc/todo.md | 104 +- go.mod | 6 +- go.sum | 6 + internal/client/api/types.go | 3 + internal/client/command/game.go | 38 +- internal/client/command/registry.go | 18 + internal/client/command/replay.go | 289 ++ internal/client/command/replay_test.go | 64 + internal/client/command/save_native.go | 10 + internal/client/command/save_wasm.go | 12 + internal/client/display/fen.go | 50 + internal/client/display/fen_test.go | 28 + internal/client/replay/replay.go | 175 ++ internal/client/replay/replay_test.go | 91 + internal/client/session/session.go | 9 +- internal/server/core/api.go | 9 +- internal/server/http/handler.go | 10 +- internal/server/processor/processor.go | 22 + internal/server/processor/rules_test.go | 29 + internal/server/service/history.go | 1 + internal/server/service/history_test.go | 3 +- .../server/webserver/chess-client-web/app.js | 2604 +++++++++++------ .../webserver/chess-client-web/index.html | 279 +- .../webserver/chess-client-web/style.css | 1527 +++++----- test/README.md | 40 +- test/e2e/.gitignore | 3 + test/e2e/package.json | 11 + test/e2e/playwright.config.js | 29 + test/e2e/replay.spec.js | 198 ++ test/run-test-server.sh | 7 +- 37 files changed, 4058 insertions(+), 1803 deletions(-) create mode 100644 internal/client/command/replay.go create mode 100644 internal/client/command/replay_test.go create mode 100644 internal/client/command/save_native.go create mode 100644 internal/client/command/save_wasm.go create mode 100644 internal/client/display/fen.go create mode 100644 internal/client/display/fen_test.go create mode 100644 internal/client/replay/replay.go create mode 100644 internal/client/replay/replay_test.go create mode 100644 test/e2e/.gitignore create mode 100644 test/e2e/package.json create mode 100644 test/e2e/playwright.config.js create mode 100644 test/e2e/replay.spec.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e435262..efb1265 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,3 +43,55 @@ jobs: run: | GOOS=freebsd GOARCH=amd64 go build -trimpath -o /dev/null ./cmd/chess-server GOOS=js GOARCH=wasm go build -trimpath -o /dev/null ./cmd/chess-client-cli + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: chess_test + POSTGRES_PASSWORD: chess_test + POSTGRES_DB: chess_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U chess_test -d chess_test" + --health-interval 2s + --health-timeout 5s + --health-retries 30 + env: + CHESS_TEST_DSN: postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.27.x' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + - name: Install Stockfish + run: sudo apt-get update -qq && sudo apt-get install -y -qq stockfish + - name: Build the server + run: make server + - name: Install Playwright and Chromium + working-directory: test/e2e + run: npm install && npx playwright install --with-deps chromium + - name: Start the test server with the web client + run: | + WEB_PORT=9090 LOG_LEVEL=info test/run-test-server.sh bin/chess-server > server.log 2>&1 & + for i in $(seq 1 60); do curl -sf http://localhost:8080/health && break; sleep 1; done + curl -sf http://localhost:8080/health + - name: Run the web client tests + working-directory: test/e2e + run: npm test + - name: Server log + if: failure() + run: tail -n 200 server.log + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: failure() + with: + name: playwright-traces + path: test/e2e/test-results/ + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 8494d42..e7a324f 100644 --- a/Makefile +++ b/Makefile @@ -167,6 +167,12 @@ test-replay: test-draw: test/test-draw.sh +# Web client end-to-end tests (Playwright); needs a server started with +# WEB_PORT=9090 test/run-test-server.sh and `npm install` in test/e2e. +.PHONY: test-e2e +test-e2e: + cd test/e2e && npm test + # Database operations .PHONY: db-init db-init: server @@ -251,6 +257,7 @@ help: @echo " make test-endstate Run end-state tests" @echo " make test-replay Run replay (SAN, PGN, listing) tests" @echo " make test-draw Run draw and resignation tests" + @echo " make test-e2e Run the web client's Playwright tests (see test/README.md)" @echo "" @echo "Database targets (CHESS_DSN=$(CHESS_DSN)):" @echo " make db-init Create or migrate the schema" diff --git a/README.md b/README.md index aa4a452..f5861da 100644 --- a/README.md +++ b/README.md @@ -167,12 +167,16 @@ The chess server includes an embedded web UI for playing games through a browser - Visual chess board with click-to-move and a promotion picker - Human vs Computer gameplay, with undo, draw offers and resignation -- Configurable engine strength (0-20) -- UCI move history with move numbers -- FEN display and custom starting positions -- Real-time server health monitoring -- User authentication support -- Responsive design for mobile devices +- Configurable engine strength (0-20) and custom starting positions +- Move list in standard algebraic notation; copy the FEN or the PGN +- **My games**: a signed-in user's stored games with thumbnails, filters and + paging; games played anonymously on the device are listed for 24 hours +- **Replay** of any stored game: step, autoplay, flip, keyboard navigation, + export FEN and PGN at any ply, and shareable links + (`?replay=&ply=`) +- Server and storage health in the top bar +- Accounts: register, sign in, delete +- Responsive layout, board beside or above the panel Access the UI at `http://localhost:9090` when server is running with `-serve` flag. @@ -184,8 +188,8 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl - [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh - [Architecture](./doc/architecture.md) - System design with auth layer - [Development](./doc/development.md) - Build, test, and user management -- [Client Guide](./doc/client.md) - Interactive debugging client -- [Replay Plan](./doc/todo.md) - Phased game-replay implementation and open decisions +- [Client Guide](./doc/client.md) - Interactive debugging client, including replay +- [Replay Plan](./doc/todo.md) - Game-replay phases, decisions, and what is left - [Stockfish Integration](./doc/stockfish.md) - Engine communication ## License diff --git a/cmd/chess-client-cli/main.go b/cmd/chess-client-cli/main.go index c1af37e..df344b4 100644 --- a/cmd/chess-client-cli/main.go +++ b/cmd/chess-client-cli/main.go @@ -114,6 +114,12 @@ func buildPrompt(s *session.Session) string { } } + // A replay replaces the turn: the live game is not what the board shows + if seg := command.ReplayPrompt(s); seg != "" { + b.Add(display.Magenta, seg) + return display.Prompt(b.String()) + } + // Add game state if available if s.CurrentGameState != nil { turnInfo := " - Turn:" diff --git a/doc/api.md b/doc/api.md index 5de3b8d..3ae161a 100644 --- a/doc/api.md +++ b/doc/api.md @@ -180,6 +180,7 @@ Authorization: Bearer "turn": "w", "state": "ongoing", "moves": [], + "san": [], "players": { "white": {"id": "550e8400-...", "color": 1, "type": 1}, "black": {"id": "ai-player-...", "color": 2, "type": 2, "level": 15, "searchTime": 1000} @@ -210,6 +211,9 @@ GET /games/{gameId}?wait=true&moveCount=5 ``` Response includes all game data. Compare `moves` array length to detect changes. +`moves` holds the UCI line and `san` the same moves in standard algebraic +notation, derived by the rules core from the position before each move (an +entry is empty only for a move it cannot notate, which the server logs). **Timeout behavior:** - Returns current state after 30 seconds even if no changes @@ -337,8 +341,9 @@ later first-move slot claim, newest first. Requires be combined with `cursor`, and pages shift when games are added. Each item contains game ID, initial FEN, final FEN (after the last stored move, -or the initial FEN when there is none), result and its `pgnResult` token, -timestamps, players, and move count. `nextCursor` is present only when another +or the initial FEN when there is none), result, its `pgnResult` token and +`termination` (as in the history; both omitted while ongoing), timestamps, +players, and move count. `nextCursor` is present only when another page exists; `nextOffset` also, on requests without a cursor. A game belongs to a user who created it while authenticated or claimed a slot with a first move; the association survives later player reconfiguration. @@ -355,6 +360,7 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter. "finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", "result": "white_wins", "pgnResult": "1-0", + "termination": "checkmate", "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", "moveCount": 7, diff --git a/doc/client.md b/doc/client.md index 8d66f1a..0dc809e 100644 --- a/doc/client.md +++ b/doc/client.md @@ -12,13 +12,13 @@ The chess client is an interactive command-line debugging tool for the chess ser - Verbose mode for detailed API request/response inspection - Long-polling support for real-time game updates -## Replay Foundation +## Replay The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`, and `GetMyGames(limit, cursor)`, including durable result, timestamps, player claims, move count, ordered UCI and SAN moves, and FEN after every move. The -`games` and `pgn` commands use them; step-by-step `replay` controls are still -planned, see [Replay Implementation Tasks](./todo.md). +`games`, `pgn` and `replay` commands use them. A replay renders the stored +FEN of each ply; the client never computes a move. ## Building ```bash @@ -44,6 +44,7 @@ The prompt dynamically updates to show context: - `chess [username - gameId]` - User in active game - `chess [username - gameId] White` - Shows player color - Turn indicator with player type (h=human, c=computer) +- `chess [...] replay 12/40` - Replaying a stored game at ply 12 of 40 ## Command Reference @@ -180,19 +181,76 @@ repetition.` or `Black wins by resignation.`; a pending offer is shown as `White offers a draw`. #### `games` / `g` -List your stored games, newest first, 20 per page (requires login). The -result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`). +List your stored games, newest first, 20 per page (requires login), numbered +for `replay <#>`. The result column is the PGN token (`1-0`, `0-1`, +`1/2-1/2`, `*`). ``` chess > games # Newest games chess > games more # Next page ``` #### `pgn` -Print a stored game as PGN (SAN movetext, standard tags). +Print a stored game as PGN (SAN movetext, standard tags), or save it with +`save `. During a replay the default is the replayed game up to the +current ply. ``` -chess > pgn # Current game, every ply -chess > pgn 10 # Current game, first 10 plies -chess > pgn [ply] # Another game +chess > pgn # Current game, every ply +chess > pgn 10 # Current game, first 10 plies +chess > pgn [ply] # Another game +chess > pgn save game.pgn # Write instead of print +``` +In the browser build there is no file system: `save` prints the PGN to copy. + +### Replay Commands + +A replay steps through a stored game's history: any game by ID (histories +are public to holders of the ID), or one of your own by its number in the +last `games` listing. The replay is separate from the live session: the +current game, login and poll position are kept, and `new`, `join`, `move`, +`computer`, `undo`, `delete`, `poll`, `resign` and `draw` are refused until +`live` ends the replay. + +#### `replay` +Load a stored game and show its initial position, the players, the result, +and the whole move list. +``` +chess > replay 2 # From the last 'games' page +chess > replay a1b2c3d4-e5f6-7890-1234-567890abcdef # Any stored game +``` + +#### `next` / `>`, `prev` / `<`, `first`, `last`, `goto` +Move through the line; each prints the board, the ply, and the move that +led to it (`2... Nf6`). `next` and `prev` take a count. +``` +chess > next +chess > next 4 +chess > prev +chess > goto 12 +chess > last +chess > first +``` + +#### `auto` +Play forward from the current ply to the end of the line, one board per ply, +with a delay in milliseconds (default 1000). +``` +chess > auto # one ply a second +chess > auto 250 +``` + +#### `fen` +Print the FEN of the current position. +``` +chess > fen +``` + +#### `show` / `h` while replaying +Shows the replayed position instead of the live game. + +#### `live` +Leave the replay and return to the live session. +``` +chess > live ``` ### Debug Commands @@ -285,10 +343,14 @@ ASCII board with colored pieces: ``` ### Move History -Displayed in UCI notation with move numbers: +The live game's history is displayed in UCI notation with move numbers: ``` History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5 ``` +A replay lists the stored game in SAN, with the current ply in brackets: +``` +1. e4 c5 2. [Nf3] Nf6 3. Nc3 e6 +``` ## Workflows @@ -319,8 +381,9 @@ History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5 ### Limitations - No move validation in client (server validates) - No algebraic notation input (UCI format only) -- Single game context at a time +- Single game context at a time (plus one replay) - No persistent authentication across restarts +- `auto` runs to the end of the line; it cannot be interrupted ## Error Handling diff --git a/doc/development.md b/doc/development.md index 6540785..7101bb5 100644 --- a/doc/development.md +++ b/doc/development.md @@ -221,6 +221,10 @@ go test ./... # Concurrency checks for the state/persistence boundary go test -race ./internal/server/storage ./internal/server/service + +# Web client end-to-end (Playwright): see test/README.md +WEB_PORT=9090 test/run-test-server.sh # terminal 1 +make test-e2e # terminal 2 ``` ## Configuration @@ -310,5 +314,5 @@ go test -race ./internal/server/storage ./internal/server/service - No push-based game updates (30-second long-polling is used) - Live games are not rehydrated after restart; persisted games are currently replay-only - Database history has no automatic retention policy -- Replay UI, PGN export, and curated games are planned in [Replay Plan](./todo.md) +- Curated games are planned in [Replay Plan](./todo.md) - REST API only diff --git a/doc/todo.md b/doc/todo.md index e9e5870..c90c212 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -31,6 +31,8 @@ into phases that each ship and test on their own. - [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below. - [x] Pawn promotion in the web client (piece picker) and a specific API error for a promotion without its piece. +- [x] Web replay UI (R3) and terminal replay commands (R4), below; `san` on + the live game response and `termination` on the listing. ## Decisions @@ -44,6 +46,9 @@ into phases that each ship and test on their own. | D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. **Refined (v3 schema):** a resignation or agreed draw between two humans is final, and undo, player changes, and unload respect other users' claims (no takebacks between players). Against the computer a concession can be undone, but the first one stays on record (`concession_*`) and in history and PGN. | | D8 | Human-vs-human in the web client | **Open.** The web client plays only against the computer; two-player games run through the API and terminal client (`join`, `poll`). A web mode would need join-by-link, a long-poll for the opponent's moves, and an offer indicator on the Draw button (accept by pressing it). | | D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. | +| D9 | Replay for visitors without an account | **Done.** The history endpoint is public by ID (D2), so the web client keeps a list of the games it created anonymously in `localStorage` (ID, side, level, last position; entries expire with the server's 24-hour retention) and shows them under **On this device** in My games. A signed-in user's list comes from the API. No server change. | +| D10 | Live SAN | **Done.** The live game response carries `san` beside `moves`, derived by the rules core from the position before each ply (the shadow check already parses it), so the web client shows SAN without an extra history request and without storage. | +| D11 | Replay history | **Done.** Views push browser history entries (`?games`, `?replay=&ply=`, or both); stepping through a replay replaces the entry so back and forward move between the live view, the list and the replay rather than ply by ply. | ## Phase R1 — Notation Core (server) — done @@ -88,29 +93,43 @@ Test gate: PGN, ETag, promotion, FEN validation, cursor paging and filters). - [x] Terminal client: `games` (cursor paging) and `pgn` commands. -## Phase R3 — Web Replay UI +## Phase R3 — Web Replay UI — done -Plain JavaScript in the embedded client, no framework. +Plain JavaScript in the embedded client, no framework. The client was +restructured around the replay: a top bar (brand, server and storage dots, +**My games**, account), the board, and a side panel with a live pane and a +replay pane; the board colours and piece glyphs are unchanged. -- [ ] **My games** button beside the account indicator (authenticated only) - opening a panel: mini-board from `finalFen`, result, date, opponent or - engine level, move count; loading, empty, error, and "load more" states. -- [ ] Replay mode entered from the panel or a deep link - `?replay=&ply=`; `history.pushState` so browser back/forward - restores the list and the ply. -- [ ] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`; - clickable SAN move list; autoplay with speed selector and pause; board flip; - current ply announced through an `aria-live` region. -- [ ] Position rendering assigns the stored FEN for the ply; the browser never - computes moves. -- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched - from R2 as a `Blob`, saved through a temporary ``). The live - view's "Copy PGN" button copies UCI moves today; point it at the PGN - endpoint too. -- [ ] Show SAN in the live move list (from the history, or a `san` field on - the live game response). -- [ ] While replaying: stop live long-polling, disable move, undo, new-game, - and player-configuration controls; **Back to live game** restores them. +- [x] **My games** button in the top bar opening a drawer: mini-board from + `finalFen`, players (the viewer's side as "You"), result and termination, + date, move count; status and colour filters; loading, empty, error (with + retry), and "load more" (cursor) states. Signed-out visitors see the games + played on this device (D9) and a sign-in note. +- [x] Replay mode entered from the drawer, the live pane's **Replay** button, + or a deep link `?replay=&ply=`; `history.pushState` per view + (D11), so browser back/forward restores the list and the ply. +- [x] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`, + `Space` (autoplay), `F` (flip), `Esc` (leave); clickable SAN move list with + the current ply marked; autoplay with a speed selector and pause; board + flip, defaulting to the viewer's side; current ply announced through an + `aria-live` region. +- [x] Position rendering assigns the stored FEN for the ply; the browser never + computes moves. The mated king and the last move are marked as in live play. +- [x] Export at the current ply: copy FEN, copy PGN (`?ply=N`, or the full + export at the last ply), download PGN (fetched as a `Blob`, saved through a + temporary ``; the host's iframe sandbox needs + `allow-downloads`), and copy a link to the position. The live view's "Copy + PGN" copies the stored game's PGN, falling back to the SAN line and FEN + when storage is off; "Copy FEN" beside it. +- [x] SAN in the live move list from the `san` field on the live game + response (D10). +- [x] While replaying: live long-polling stops, the live pane (move, undo, + new game, draw, resign) hides and square clicks are ignored; **Back to + live game** (or **Close replay** without one) restores them and resumes + polling if the computer was thinking. +- [x] History responses are revalidated with `If-None-Match`; `ETag` and + `Content-Disposition` are exposed over CORS for the development setup, + where the web client and the API are on different ports. Constraints from the host's security headers: @@ -128,20 +147,39 @@ Constraints from the host's security headers: Test gate: -- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a - short game, open My games, step backward and forward, autoplay to the end, - and compare exported FEN/PGN with the API. -- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion - disables autoplay animation. -- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`. +- [x] Playwright (Chromium) end-to-end against server and PostgreSQL + (`test/e2e`, run by the `e2e` CI job): play a short game, open My games, + step backward and forward with buttons and keys, autoplay to the end, + compare the exported FEN and PGN with the API, download, copy a link, + browser back through replay and list; a deep link to a checkmate; the + device list for an anonymous visitor. +- [x] Keyboard-only navigation and labels: every control is a button with a + name, the move list is a list of buttons, the current ply has + `aria-current` and is announced; `prefers-reduced-motion` disables the + client's animations and transitions (autoplay itself is user-initiated and + stays). +- [x] Deep link: the client reads `?replay=` from its own URL, so it works + wherever the document is published (`/chess/web-client/` on lixen.com, + with the API at `/chess`); the old `/projects/chess/` path redirects there. -## Phase R4 — CLI and WASM Replay (optional) +## Phase R4 — CLI and WASM Replay — done -- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`. -- [ ] `replay `, `next`, `prev`, `first`, `last`, `goto `, - `auto `, `fen`, `pgn save `. -- [ ] Replay state is separate from the live session: no polling, moves, undo, - or configuration while replaying. +- [x] `games` (list with cursor paging, numbered) and `pgn [gameId] [ply]`. +- [x] `replay ` (index into the last `games` page), `next` + `prev` (`>` `<`, with a count), `first`, `last`, `goto `, `auto [ms]`, + `fen`, `pgn [save ]` (the replayed game up to the current ply; in + the browser build the PGN is printed instead of saved), `live` to leave. +- [x] Replay state is separate from the live session: `new`, `join`, `move`, + `computer`, `undo`, `delete`, `poll`, `resign` and `draw` are refused + while replaying, `show` shows the replayed position, and the prompt reads + `replay /`; `live` returns to the game as it was. + +Later for the replay clients: + +- [ ] Web: an "Analysis" link on a replay (chess.com-style evaluation needs + an engine in the browser or a server endpoint; none exists). +- [ ] Web: a light theme, following the host page's choice. +- [ ] CLI: `auto` cannot be interrupted before the end of the line. ## Phase R5 — Curated Archive (later) diff --git a/go.mod b/go.mod index 2ba8d46..642ba46 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gabriel-vasile/mimetype v1.4.16 // indirect github.com/go-playground/locales v0.14.2 // indirect github.com/go-playground/universal-translator v0.18.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect @@ -26,10 +26,10 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect github.com/molecule-man/go-brrr v1.3.0 // indirect - github.com/philhofer/fwd v1.2.0 // indirect + github.com/philhofer/fwd v1.2.1 // indirect github.com/tinylib/msgp v1.6.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.75.0 // indirect + github.com/valyala/fasthttp v1.76.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index a7007e9..b6a183f 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E= +github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= @@ -43,6 +45,8 @@ github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjR github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ= +github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -56,6 +60,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA= github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s= +github.com/valyala/fasthttp v1.76.0 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw= +github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= diff --git a/internal/client/api/types.go b/internal/client/api/types.go index 0ff1b93..6bdc4da 100644 --- a/internal/client/api/types.go +++ b/internal/client/api/types.go @@ -45,6 +45,7 @@ type GameResponse struct { DrawOutcome string `json:"drawOutcome,omitempty"` Concession *Concession `json:"concession,omitempty"` Moves []string `json:"moves"` + SAN []string `json:"san"` Players PlayersResponse `json:"players"` LastMove *MoveInfo `json:"lastMove,omitempty"` } @@ -129,6 +130,7 @@ type GameHistoryResponse struct { Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` + Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins") Players PlayersResponse `json:"players"` Moves []HistoryMove `json:"moves"` } @@ -148,6 +150,7 @@ type GameSummary struct { FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` PGNResult string `json:"pgnResult"` + Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` diff --git a/internal/client/command/game.go b/internal/client/command/game.go index 6b460b4..6edac44 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -104,8 +104,8 @@ func (r *Registry) registerGameCommands() { r.Register(&Command{ Name: "pgn", ShortName: "", - Description: "Print a stored game as PGN", - Usage: "pgn [gameId] [ply] (default: current game, all plies)", + Description: "Print or save a stored game as PGN", + Usage: "pgn [gameId] [ply] [save ] (default: current or replayed game)", Handler: pgnHandler, }) @@ -520,6 +520,9 @@ func undoHandler(s *session.Session, args []string) error { } func showBoardHandler(s *session.Session, args []string) error { + if s.Replay != nil { + return showReplayPosition(s) + } gameID := s.GetCurrentGame() if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") @@ -692,19 +695,21 @@ func gamesHandler(s *session.Session, args []string) error { return err } s.GamesCursor = resp.NextCursor + s.GamesPage = resp.Games if len(resp.Games) == 0 { display.Println(display.Yellow, "No stored games") return nil } - fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)") - for _, g := range resp.Games { - fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID, + fmt.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)") + for i, g := range resp.Games { + fmt.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID, playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount, g.StartTimeUTC.UTC().Format("2006-01-02 15:04")) } + display.Println(display.Cyan, "\nReplay one: replay <#> or replay ") if resp.NextCursor != "" { - display.Println(display.Cyan, "\nMore games: games more") + display.Println(display.Cyan, "More games: games more") } return nil } @@ -720,9 +725,21 @@ func playerLabel(p api.PlayerInfo) string { } // pgnHandler prints the stored game as PGN: the current game unless an ID is -// given, and every ply unless a count is given. +// given, and every ply unless a count is given. During a replay the default +// is the replayed game up to the current ply. "save " writes the +// export to a file instead of printing it. func pgnHandler(s *session.Session, args []string) error { + path, args, err := pgnSaveArgs(args) + if err != nil { + return err + } gameID, ply := s.CurrentGame, -1 + if s.Replay != nil { + gameID = s.Replay.History.GameID + if !s.Replay.AtEnd() { + ply = s.Replay.Ply + } + } for _, arg := range args { if n, err := strconv.Atoi(arg); err == nil && n >= 0 { ply = n @@ -737,6 +754,13 @@ func pgnHandler(s *session.Session, args []string) error { if err != nil { return err } + if path != "" { + if err := savePGN(path, trimPGN(text)); err != nil { + return err + } + display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path) + return nil + } fmt.Println() fmt.Print(text) return nil diff --git a/internal/client/command/registry.go b/internal/client/command/registry.go index fb06958..13cb552 100644 --- a/internal/client/command/registry.go +++ b/internal/client/command/registry.go @@ -32,6 +32,7 @@ func NewRegistry(s *session.Session) *Registry { // Register all commands r.registerGameCommands() + r.registerReplayCommands() r.registerAuthCommands() r.registerDebugCommands() @@ -78,6 +79,10 @@ func (r *Registry) Execute(input string) { display.Println(display.Reset, "Type 'help' for available commands") return } + if r.session.Replay != nil && liveOnly[cmd.Name] { + display.Println(display.Red, "'%s' is not available while replaying; 'live' returns to the live session", cmd.Name) + return + } // Set verbose mode in client if session supports it if cl, ok := r.session.GetClient().(*api.Client); ok { @@ -132,6 +137,18 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { {"pgn", "", ""}, } + replayCommands := []cmdInfo{ + {"replay", "", ""}, + {"next", ">", ""}, + {"prev", "<", ""}, + {"first", "", ""}, + {"last", "", ""}, + {"goto", "", ""}, + {"auto", "", ""}, + {"fen", "", ""}, + {"live", "", ""}, + } + authCommands := []cmdInfo{ {"register", "r", ""}, {"login", "l", ""}, @@ -163,6 +180,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { } printCommandGroup("Game Commands", gameCommands) + printCommandGroup("Replay Commands", replayCommands) printCommandGroup("Auth Commands", authCommands) printCommandGroup("Utility Commands", utilCommands) diff --git a/internal/client/command/replay.go b/internal/client/command/replay.go new file mode 100644 index 0000000..7adcc6e --- /dev/null +++ b/internal/client/command/replay.go @@ -0,0 +1,289 @@ +package command + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/lixenwraith/chess/internal/client/display" + "github.com/lixenwraith/chess/internal/client/replay" + "github.com/lixenwraith/chess/internal/client/session" +) + +// Replay commands step through a stored game from its history. The replay +// state lives beside the live session, which keeps its game, user and poll +// position; live commands that would change the game are refused until +// 'live' ends the replay (see liveOnly). +func (r *Registry) registerReplayCommands() { + r.Register(&Command{ + Name: "replay", + ShortName: "", + Description: "Step through a stored game", + Usage: "replay (index from the last 'games' listing)", + Handler: replayHandler, + }) + r.Register(&Command{ + Name: "next", + ShortName: ">", + Description: "Replay: next ply", + Usage: "next [count]", + Handler: stepHandler(1), + }) + r.Register(&Command{ + Name: "prev", + ShortName: "<", + Description: "Replay: previous ply", + Usage: "prev [count]", + Handler: stepHandler(-1), + }) + r.Register(&Command{ + Name: "first", + ShortName: "", + Description: "Replay: initial position", + Usage: "first", + Handler: func(s *session.Session, args []string) error { + return gotoHandler(s, []string{"0"}) + }, + }) + r.Register(&Command{ + Name: "last", + ShortName: "", + Description: "Replay: final position", + Usage: "last", + Handler: func(s *session.Session, args []string) error { + if s.Replay == nil { + return errNotReplaying + } + return gotoHandler(s, []string{strconv.Itoa(s.Replay.Len())}) + }, + }) + r.Register(&Command{ + Name: "goto", + ShortName: "", + Description: "Replay: show the position after N plies", + Usage: "goto ", + Handler: gotoHandler, + }) + r.Register(&Command{ + Name: "auto", + ShortName: "", + Description: "Replay: play forward to the end", + Usage: "auto [ms] (delay per ply, default 1000)", + Handler: autoHandler, + }) + r.Register(&Command{ + Name: "fen", + ShortName: "", + Description: "Replay: print the FEN of the current position", + Usage: "fen", + Handler: fenHandler, + }) + r.Register(&Command{ + Name: "live", + ShortName: "", + Description: "Leave the replay and return to the live session", + Usage: "live", + Handler: liveHandler, + }) +} + +var errNotReplaying = fmt.Errorf("no replay in progress; use 'replay '") + +// liveOnly names the commands a replay refuses: they would change the live +// game or poll it, while a replay only reads a stored one. +var liveOnly = map[string]bool{ + "new": true, "join": true, "move": true, "computer": true, "undo": true, + "delete": true, "poll": true, "resign": true, "draw": true, +} + +// replayHandler loads a stored game by ID, or by its position in the last +// 'games' listing, and shows its initial position. +func replayHandler(s *session.Session, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: replay ") + } + gameID := args[0] + if index, err := strconv.Atoi(gameID); err == nil { + if index < 1 || index > len(s.GamesPage) { + if len(s.GamesPage) == 0 { + return fmt.Errorf("no listing to index; run 'games' first or give a game ID") + } + return fmt.Errorf("index %d is outside the last listing (1-%d)", index, len(s.GamesPage)) + } + gameID = s.GamesPage[index-1].GameID + } + + history, err := s.Client.GetGameHistory(gameID) + if err != nil { + return err + } + s.Replay = replay.New(history) + + white, black := s.Replay.Players() + display.Println(display.Cyan, "\nReplaying %s", history.GameID) + fmt.Printf("%s vs %s · %s · %d plies · started %s\n", + display.C(display.Blue, white), display.C(display.Red, black), s.Replay.Result(), s.Replay.Len(), + history.StartTimeUTC.UTC().Format("2006-01-02 15:04")) + if c := history.Concession; c != nil { + what := "a draw was agreed" + switch c.Result { + case "white_wins": + what = "Black resigned" + case "black_wins": + what = "White resigned" + } + display.Println(display.Yellow, "%s at ply %d; play continued.", what, c.Ply) + } + fmt.Println() + fmt.Println(s.Replay.Movetext(80)) + display.Println(display.Reset, "\nnext, prev, first, last, goto , auto [ms], fen, pgn [save ], live") + return showReplayPosition(s) +} + +func stepHandler(direction int) func(*session.Session, []string) error { + return func(s *session.Session, args []string) error { + if s.Replay == nil { + return errNotReplaying + } + count := 1 + if len(args) > 0 { + n, err := strconv.Atoi(args[0]) + if err != nil || n < 1 { + return fmt.Errorf("invalid count: %s", args[0]) + } + count = n + } + before := s.Replay.Ply + s.Replay.Goto(before + direction*count) + if s.Replay.Ply == before { + if direction > 0 { + display.Println(display.Yellow, "At the end of the game") + } else { + display.Println(display.Yellow, "At the initial position") + } + } + return showReplayPosition(s) + } +} + +func gotoHandler(s *session.Session, args []string) error { + if s.Replay == nil { + return errNotReplaying + } + if len(args) < 1 { + return fmt.Errorf("usage: goto (0-%d)", s.Replay.Len()) + } + ply, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid ply: %s", args[0]) + } + if ply < 0 || ply > s.Replay.Len() { + return fmt.Errorf("ply %d is outside 0-%d", ply, s.Replay.Len()) + } + s.Replay.Goto(ply) + return showReplayPosition(s) +} + +// autoHandler plays forward from the current ply to the end, one board per +// ply, pausing between them. +func autoHandler(s *session.Session, args []string) error { + if s.Replay == nil { + return errNotReplaying + } + delay := 1000 + if len(args) > 0 { + n, err := strconv.Atoi(args[0]) + if err != nil || n < 0 || n > 60000 { + return fmt.Errorf("delay must be 0-60000 ms") + } + delay = n + } + if s.Replay.AtEnd() { + display.Println(display.Yellow, "At the end of the game; 'first' to start over") + return nil + } + for !s.Replay.AtEnd() { + time.Sleep(time.Duration(delay) * time.Millisecond) + s.Replay.Next() + if err := showReplayPosition(s); err != nil { + return err + } + } + return nil +} + +func fenHandler(s *session.Session, args []string) error { + if s.Replay == nil { + return errNotReplaying + } + fmt.Println(s.Replay.FEN()) + return nil +} + +func liveHandler(s *session.Session, args []string) error { + if s.Replay == nil { + display.Println(display.Yellow, "Not replaying") + return nil + } + s.Replay = nil + display.Println(display.Green, "Back to the live session") + if s.CurrentGame != "" { + display.Println(display.Cyan, "Current game: %s", s.CurrentGame) + } + return nil +} + +// showReplayPosition prints the board at the current ply with its label. +func showReplayPosition(s *session.Session) error { + r := s.Replay + board, err := display.BoardFromFEN(r.FEN()) + if err != nil { + return fmt.Errorf("stored position cannot be shown: %w", err) + } + fmt.Println() + display.RenderBoard(board) + label := r.Label(r.Ply) + if r.Ply == 0 { + label = "initial position" + } + fmt.Printf("\nPly %d/%d %s\n", r.Ply, r.Len(), display.C(display.Cyan, label)) + if r.AtEnd() && r.History.Result != "" { + display.Println(display.Green, "Result: %s", r.Result()) + } + return nil +} + +// replayPrompt is the prompt segment while replaying. +func ReplayPrompt(s *session.Session) string { + if s.Replay == nil { + return "" + } + return fmt.Sprintf(" replay %d/%d", s.Replay.Ply, s.Replay.Len()) +} + +// pgnSaveArgs splits "pgn save [gameId] [ply]" style arguments: +// a "save" followed by a path, and the remaining arguments. +func pgnSaveArgs(args []string) (path string, rest []string, err error) { + for i, arg := range args { + if arg == "save" { + if i+1 >= len(args) { + return "", nil, fmt.Errorf("usage: pgn save ") + } + rest = append(append([]string{}, args[:i]...), args[i+2:]...) + return args[i+1], rest, nil + } + } + return "", args, nil +} + +func describePGNTarget(gameID string, ply int) string { + if ply >= 0 { + return fmt.Sprintf("%s (first %d plies)", gameID, ply) + } + return gameID +} + +func trimPGN(text string) string { + return strings.TrimRight(text, "\n") + "\n" +} diff --git a/internal/client/command/replay_test.go b/internal/client/command/replay_test.go new file mode 100644 index 0000000..f6c3c43 --- /dev/null +++ b/internal/client/command/replay_test.go @@ -0,0 +1,64 @@ +package command + +import ( + "testing" + + "github.com/lixenwraith/chess/internal/client/api" + "github.com/lixenwraith/chess/internal/client/replay" + "github.com/lixenwraith/chess/internal/client/session" +) + +func TestPGNSaveArgs(t *testing.T) { + for name, tc := range map[string]struct { + args []string + path string + rest []string + err bool + }{ + "none": {[]string{"abc", "3"}, "", []string{"abc", "3"}, false}, + "save last": {[]string{"abc", "save", "out.pgn"}, "out.pgn", []string{"abc"}, false}, + "save first": {[]string{"save", "out.pgn", "abc", "3"}, "out.pgn", []string{"abc", "3"}, false}, + "missing path": {[]string{"save"}, "", nil, true}, + } { + path, rest, err := pgnSaveArgs(tc.args) + if (err != nil) != tc.err || path != tc.path || len(rest) != len(tc.rest) { + t.Errorf("%s: path %q rest %v err %v", name, path, rest, err) + continue + } + for i := range rest { + if rest[i] != tc.rest[i] { + t.Errorf("%s: rest = %v, want %v", name, rest, tc.rest) + } + } + } +} + +func TestReplayRefusesLiveCommandsAndPrompt(t *testing.T) { + s := &session.Session{APIBaseURL: "http://127.0.0.1:1", Client: api.New("http://127.0.0.1:1")} + r := NewRegistry(s) + if ReplayPrompt(s) != "" { + t.Error("prompt segment while live") + } + s.Replay = replay.New(&api.GameHistoryResponse{ + GameID: "g", InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + Moves: []api.HistoryMove{{MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", PlayerColor: "w"}}, + }) + if got := ReplayPrompt(s); got != " replay 0/1" { + t.Errorf("prompt segment = %q", got) + } + // A live command is refused without touching the client (no server). + r.Execute("move e2e4") + r.Execute("next") + if s.Replay.Ply != 1 || ReplayPrompt(s) != " replay 1/1" { + t.Errorf("after next: ply %d", s.Replay.Ply) + } + r.Execute("live") + if s.Replay != nil { + t.Error("live did not end the replay") + } + for _, name := range []string{"new", "join", "move", "computer", "undo", "delete", "poll", "resign", "draw"} { + if !liveOnly[name] { + t.Errorf("%s is not refused while replaying", name) + } + } +} diff --git a/internal/client/command/save_native.go b/internal/client/command/save_native.go new file mode 100644 index 0000000..d6386f7 --- /dev/null +++ b/internal/client/command/save_native.go @@ -0,0 +1,10 @@ +//go:build !js && !wasm + +package command + +import "os" + +// savePGN writes a PGN export to path. +func savePGN(path, text string) error { + return os.WriteFile(path, []byte(text), 0o644) +} diff --git a/internal/client/command/save_wasm.go b/internal/client/command/save_wasm.go new file mode 100644 index 0000000..34a1963 --- /dev/null +++ b/internal/client/command/save_wasm.go @@ -0,0 +1,12 @@ +//go:build js && wasm + +package command + +import "fmt" + +// savePGN has no file system in the browser: the PGN is printed instead, +// to be copied from the terminal. +func savePGN(path, text string) error { + fmt.Print(text) + return fmt.Errorf("files cannot be saved from the browser; the PGN is printed above") +} diff --git a/internal/client/display/fen.go b/internal/client/display/fen.go new file mode 100644 index 0000000..a688c88 --- /dev/null +++ b/internal/client/display/fen.go @@ -0,0 +1,50 @@ +package display + +import ( + "fmt" + "strings" +) + +// BoardFromFEN renders a FEN's piece placement in the server's ASCII board +// format (files on the first and last lines, ranks at both ends of a row, +// "." for an empty square), so RenderBoard colors it the same way. +func BoardFromFEN(fen string) (string, error) { + fields := strings.Fields(fen) + if len(fields) == 0 { + return "", fmt.Errorf("empty FEN") + } + rows := strings.Split(fields[0], "/") + if len(rows) != 8 { + return "", fmt.Errorf("FEN has %d ranks, want 8", len(rows)) + } + var b strings.Builder + b.WriteString(" a b c d e f g h\n") + for i, row := range rows { + rank := byte('8' - i) + b.WriteByte(rank) + b.WriteByte(' ') + files := 0 + for _, c := range row { + switch { + case c >= '1' && c <= '8': + for n := 0; n < int(c-'0'); n++ { + b.WriteString(". ") + } + files += int(c - '0') + case strings.ContainsRune("pnbrqkPNBRQK", c): + b.WriteRune(c) + b.WriteByte(' ') + files++ + default: + return "", fmt.Errorf("invalid FEN character %q", c) + } + } + if files != 8 { + return "", fmt.Errorf("rank %c has %d files, want 8", rank, files) + } + b.WriteByte(rank) + b.WriteByte('\n') + } + b.WriteString(" a b c d e f g h") + return b.String(), nil +} diff --git a/internal/client/display/fen_test.go b/internal/client/display/fen_test.go new file mode 100644 index 0000000..f89e379 --- /dev/null +++ b/internal/client/display/fen_test.go @@ -0,0 +1,28 @@ +package display + +import "testing" + +func TestBoardFromFEN(t *testing.T) { + got, err := BoardFromFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1") + if err != nil { + t.Fatal(err) + } + want := " a b c d e f g h\n" + + "8 r n b q k b n r 8\n" + + "7 p p p p p p p p 7\n" + + "6 . . . . . . . . 6\n" + + "5 . . . . . . . . 5\n" + + "4 . . . . P . . . 4\n" + + "3 . . . . . . . . 3\n" + + "2 P P P P . P P P 2\n" + + "1 R N B Q K B N R 1\n" + + " a b c d e f g h" + if got != want { + t.Errorf("board:\n%s\nwant:\n%s", got, want) + } + for _, bad := range []string{"", "8/8/8/8/8/8/8 w - - 0 1", "9/8/8/8/8/8/8/8 w - - 0 1", "x7/8/8/8/8/8/8/8 w - - 0 1"} { + if _, err := BoardFromFEN(bad); err == nil { + t.Errorf("BoardFromFEN(%q) accepted", bad) + } + } +} diff --git a/internal/client/replay/replay.go b/internal/client/replay/replay.go new file mode 100644 index 0000000..1599f66 --- /dev/null +++ b/internal/client/replay/replay.go @@ -0,0 +1,175 @@ +// Package replay steps through a stored game's history in the terminal +// client. The state is separate from the live session: it holds the +// history the server returned and a ply, and renders positions from the +// stored FENs; it never computes a move. +package replay + +import ( + "fmt" + "strconv" + "strings" + + "github.com/lixenwraith/chess/internal/client/api" +) + +// State is a stored game and the ply shown: 0 is the initial position, +// Len() the position after the last move. +type State struct { + History *api.GameHistoryResponse + Ply int +} + +// New starts a replay at the initial position. +func New(history *api.GameHistoryResponse) *State { + return &State{History: history} +} + +// Len is the number of stored plies. +func (s *State) Len() int { return len(s.History.Moves) } + +// Goto shows the position after ply plies, clamped to the stored line, and +// returns the ply shown. +func (s *State) Goto(ply int) int { + if ply < 0 { + ply = 0 + } + if ply > s.Len() { + ply = s.Len() + } + s.Ply = ply + return s.Ply +} + +func (s *State) Next() int { return s.Goto(s.Ply + 1) } +func (s *State) Prev() int { return s.Goto(s.Ply - 1) } +func (s *State) First() int { return s.Goto(0) } +func (s *State) Last() int { return s.Goto(s.Len()) } + +// AtEnd reports whether the last stored position is shown. +func (s *State) AtEnd() bool { return s.Ply >= s.Len() } + +// FEN is the stored position at the current ply. +func (s *State) FEN() string { + if s.Ply == 0 { + return s.History.InitialFEN + } + return s.History.Moves[s.Ply-1].FENAfterMove +} + +// Move is the ply-th move (from 1), or nil for the initial position. +func (s *State) Move(ply int) *api.HistoryMove { + if ply < 1 || ply > s.Len() { + return nil + } + return &s.History.Moves[ply-1] +} + +// startsWithBlack and firstMoveNumber come from the initial FEN, so a game +// from a custom position numbers its moves as its PGN does. +func (s *State) start() (number int, black bool) { + parts := strings.Fields(s.History.InitialFEN) + number = 1 + if len(parts) >= 6 { + if n, err := strconv.Atoi(parts[5]); err == nil && n > 0 { + number = n + } + } + black = len(parts) >= 2 && parts[1] == "b" + return number, black +} + +// Label names a ply in PGN style: "2. Nf3", "2... Nf6", or "start". +func (s *State) Label(ply int) string { + move := s.Move(ply) + if move == nil { + return "start" + } + first, black := s.start() + offset := ply - 1 + if black { + offset++ + } + number := first + offset/2 + text := move.SAN + if text == "" { + text = move.MoveUCI + } + if move.PlayerColor == "b" { + return fmt.Sprintf("%d... %s", number, text) + } + return fmt.Sprintf("%d. %s", number, text) +} + +// Movetext lists the moves with numbers, the current ply in brackets, +// wrapped at width columns (80 when width is not positive). +func (s *State) Movetext(width int) string { + if width <= 0 { + width = 80 + } + first, black := s.start() + var tokens []string + number := first + for i, move := range s.History.Moves { + text := move.SAN + if text == "" { + text = move.MoveUCI + } + if i+1 == s.Ply { + text = "[" + text + "]" + } + switch { + case i == 0 && black: + tokens = append(tokens, fmt.Sprintf("%d...", number), text) + number++ + case move.PlayerColor == "w": + tokens = append(tokens, fmt.Sprintf("%d.", number), text) + default: + tokens = append(tokens, text) + number++ + } + } + if len(tokens) == 0 { + return "(no moves)" + } + var b strings.Builder + col := 0 + for _, token := range tokens { + if col > 0 && col+1+len(token) > width { + b.WriteByte('\n') + col = 0 + } else if col > 0 { + b.WriteByte(' ') + col++ + } + b.WriteString(token) + col += len(token) + } + return b.String() +} + +// Players names the sides: the stored name, the engine and level, or +// "Anonymous". +func (s *State) Players() (white, black string) { + return playerLabel(s.History.Players.White), playerLabel(s.History.Players.Black) +} + +func playerLabel(p api.PlayerInfo) string { + switch { + case p.Type == 2: + return fmt.Sprintf("Stockfish L%d", p.Level) + case p.Name != "": + return p.Name + } + return "Anonymous" +} + +// Result describes the outcome: "1-0 (checkmate)" or "ongoing". +func (s *State) Result() string { + if s.History.Result == "" { + return "ongoing" + } + if s.History.Termination != "" { + return fmt.Sprintf("%s (%s)", s.History.PGNResult, strings.ReplaceAll(s.History.Termination, "_", " ")) + } + return s.History.PGNResult +} diff --git a/internal/client/replay/replay_test.go b/internal/client/replay/replay_test.go new file mode 100644 index 0000000..720b33d --- /dev/null +++ b/internal/client/replay/replay_test.go @@ -0,0 +1,91 @@ +package replay + +import ( + "testing" + + "github.com/lixenwraith/chess/internal/client/api" +) + +func TestNavigationClampsAndSelectsStoredFEN(t *testing.T) { + h := &api.GameHistoryResponse{ + InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + Moves: []api.HistoryMove{ + {MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "fen1", PlayerColor: "w"}, + {MoveNumber: 2, MoveUCI: "c7c5", SAN: "c5", FENAfterMove: "fen2", PlayerColor: "b"}, + {MoveNumber: 3, MoveUCI: "g1f3", SAN: "Nf3", FENAfterMove: "fen3", PlayerColor: "w"}, + }, + } + s := New(h) + if s.Ply != 0 || s.FEN() != h.InitialFEN || s.Label(0) != "start" { + t.Fatalf("new state: ply %d fen %q label %q", s.Ply, s.FEN(), s.Label(0)) + } + if got := s.Next(); got != 1 || s.FEN() != "fen1" || s.Label(1) != "1. e4" { + t.Errorf("next: ply %d fen %q label %q", got, s.FEN(), s.Label(1)) + } + if got := s.Last(); got != 3 || !s.AtEnd() || s.Label(3) != "2. Nf3" { + t.Errorf("last: ply %d atEnd %v label %q", got, s.AtEnd(), s.Label(3)) + } + if got := s.Next(); got != 3 { + t.Errorf("next past the end = %d", got) + } + if got := s.Goto(-5); got != 0 || s.FEN() != h.InitialFEN { + t.Errorf("goto -5 = %d", got) + } + if got := s.Goto(2); got != 2 || s.Label(2) != "1... c5" || s.FEN() != "fen2" { + t.Errorf("goto 2 = %d label %q", got, s.Label(2)) + } + if got := s.Prev(); got != 1 { + t.Errorf("prev = %d", got) + } + if s.Move(0) != nil || s.Move(4) != nil || s.Move(3).SAN != "Nf3" { + t.Error("Move bounds") + } + want := "1. e4 c5 2. [Nf3]" + s.Last() + if got := s.Movetext(80); got != want { + t.Errorf("movetext = %q, want %q", got, want) + } + if got := s.Movetext(8); got != "1. e4 c5\n2. [Nf3]" { + t.Errorf("wrapped movetext = %q", got) + } + if s.Result() != "ongoing" { + t.Errorf("result = %q", s.Result()) + } + h.Result, h.PGNResult, h.Termination = "white_wins", "1-0", "checkmate" + if s.Result() != "1-0 (checkmate)" { + t.Errorf("result = %q", s.Result()) + } +} + +func TestCustomStartNumbersFromItsMoveAndSide(t *testing.T) { + h := &api.GameHistoryResponse{ + InitialFEN: "4k3/8/8/8/8/8/8/4K2R b K - 0 40", + Moves: []api.HistoryMove{ + {MoveNumber: 1, MoveUCI: "e8d8", SAN: "Kd8", FENAfterMove: "f1", PlayerColor: "b"}, + {MoveNumber: 2, MoveUCI: "e1g1", SAN: "O-O", FENAfterMove: "f2", PlayerColor: "w"}, + {MoveNumber: 3, MoveUCI: "d8c8", SAN: "Kc8", FENAfterMove: "f3", PlayerColor: "b"}, + }, + } + s := New(h) + for ply, want := range map[int]string{1: "40... Kd8", 2: "41. O-O", 3: "41... Kc8"} { + if got := s.Label(ply); got != want { + t.Errorf("label %d = %q, want %q", ply, got, want) + } + } + s.Goto(2) + if got := s.Movetext(80); got != "40... Kd8 41. [O-O] Kc8" { + t.Errorf("movetext = %q", got) + } + if w, b := s.Players(); w != "Anonymous" || b != "Anonymous" { + t.Errorf("players = %q %q", w, b) + } + h.Players.White = api.PlayerInfo{Type: 2, Level: 7} + h.Players.Black = api.PlayerInfo{Type: 1, Name: "alice"} + if w, b := s.Players(); w != "Stockfish L7" || b != "alice" { + t.Errorf("players = %q %q", w, b) + } + empty := New(&api.GameHistoryResponse{InitialFEN: h.InitialFEN}) + if empty.Movetext(80) != "(no moves)" || empty.Last() != 0 { + t.Error("empty history") + } +} diff --git a/internal/client/session/session.go b/internal/client/session/session.go index 0309a84..2d74fcf 100644 --- a/internal/client/session/session.go +++ b/internal/client/session/session.go @@ -2,6 +2,7 @@ package session import ( "github.com/lixenwraith/chess/internal/client/api" + "github.com/lixenwraith/chess/internal/client/replay" ) // Session maintains client state and configuration @@ -12,9 +13,13 @@ type Session struct { AuthToken string Username string LastMoveCount int - GamesCursor string // next page of the "games" listing + GamesCursor string // next page of the "games" listing + GamesPage []api.GameSummary // the last "games" page, for "replay " Client *api.Client - Verbose bool + // Replay is the stored game being stepped through, nil while live. The + // live game, user, and poll state above are untouched by a replay. + Replay *replay.State + Verbose bool // Game state for prompt CurrentGameState *api.GameResponse PlayerColor string // "w", "b", or "" diff --git a/internal/server/core/api.go b/internal/server/core/api.go index d91eee2..f9940b4 100644 --- a/internal/server/core/api.go +++ b/internal/server/core/api.go @@ -42,7 +42,9 @@ type UndoRequest struct { // DrawOffer is the color whose draw offer awaits an answer; DrawOutcome // reports what a draw request did ("offered", "accepted", "declined") and is // set only on responses to that request. Concession is the game's first -// resignation or agreed draw, kept after an undo continues play. +// resignation or agreed draw, kept after an undo continues play. SAN runs +// parallel to Moves, derived by the rules core from the position before each +// move; an entry is empty only for a move the core cannot notate. type GameResponse struct { GameID string `json:"gameId"` FEN string `json:"fen"` @@ -53,6 +55,7 @@ type GameResponse struct { DrawOutcome string `json:"drawOutcome,omitempty"` Concession *Concession `json:"concession,omitempty"` Moves []string `json:"moves"` + SAN []string `json:"san"` Players PlayersResponse `json:"players"` LastMove *MoveInfo `json:"lastMove,omitempty"` } @@ -113,13 +116,15 @@ type HistoryMove struct { // GameSummary is intentionally sufficient for a client-side game picker; the // full move list remains on the per-game history endpoint. FinalFEN is the -// position after the last stored move (the initial FEN when there is none). +// position after the last stored move (the initial FEN when there is none); +// Termination says how a finished game ended, as in the history. type GameSummary struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` PGNResult string `json:"pgnResult"` + Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 4012823..1e67d14 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -93,10 +93,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) TimeZone: "UTC", })) } + // ETag and Content-Disposition are not CORS-safelisted: a browser client + // on another origin (the embedded web server's port, in development) can + // only revalidate histories and name PGN downloads when they are exposed. app.Use(cors.New(cors.Config{ - AllowOrigins: "*", - AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", - AllowHeaders: "Origin,Content-Type,Accept,Authorization", + AllowOrigins: "*", + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization,If-None-Match", + ExposeHeaders: "ETag,Content-Disposition", })) // Health check (no rate limit) diff --git a/internal/server/processor/processor.go b/internal/server/processor/processor.go index 4087b0a..f5ab8c9 100644 --- a/internal/server/processor/processor.go +++ b/internal/server/processor/processor.go @@ -674,6 +674,7 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo State: g.State.String(), Termination: string(g.Termination), Moves: g.Moves, + SAN: notateLine(g.FENs, g.Moves), Players: core.PlayersResponse{ White: g.WhitePlayer, Black: g.BlackPlayer, @@ -721,6 +722,27 @@ func (p *Processor) Close() error { return errors.Join(queueErr, engineErr) } +// notateLine derives SAN for each live move from the position before it, +// fens[i] being the position before moves[i]. Each ply stands alone, so a +// position the core cannot parse leaves only that entry empty (and is logged +// once, by the shadow check, when the move was made). +func notateLine(fens, moves []string) []string { + san := make([]string, len(moves)) + for i, uci := range moves { + if i >= len(fens) { + break + } + pos, err := chess.ParseFEN(fens[i]) + if err != nil { + continue + } + if m, err := pos.ParseUCI(uci); err == nil { + san[i] = pos.SAN(m) + } + } + return san +} + // rulesResult is the rules core's verdict on a move, computed alongside the // engine's. Stockfish stays authoritative; a disagreement is logged so the // core can be trusted, or fixed, before it validates anything. diff --git a/internal/server/processor/rules_test.go b/internal/server/processor/rules_test.go index 68a53ef..7621041 100644 --- a/internal/server/processor/rules_test.go +++ b/internal/server/processor/rules_test.go @@ -2,6 +2,8 @@ package processor import ( "errors" + "reflect" + "strings" "testing" "github.com/lixenwraith/chess/internal/server/chess" @@ -25,6 +27,33 @@ func TestRulesCheck(t *testing.T) { } } +func TestNotateLine(t *testing.T) { + line, err := chess.Replay(chess.StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"}) + if err != nil { + t.Fatal(err) + } + fens := make([]string, 6) + for i := range fens { + fens[i] = line.Position(i).FEN() + } + moves := []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"} + if got := strings.Join(notateLine(fens, moves), " "); got != "e4 e5 Nf3 Nc6 Bb5" { + t.Errorf("SAN = %q", got) + } + // A ply the core cannot notate leaves only its own entry empty. + broken := append([]string(nil), fens...) + broken[2] = "not a fen" + if got := notateLine(broken, moves); !reflect.DeepEqual(got, []string{"e4", "e5", "", "Nc6", "Bb5"}) { + t.Errorf("SAN with a broken position = %q", got) + } + if got := notateLine(nil, nil); len(got) != 0 { + t.Errorf("SAN of no moves = %q", got) + } + if got := notateLine(fens[:1], moves[:2]); !reflect.DeepEqual(got, []string{"e4", ""}) { + t.Errorf("SAN short of positions = %q", got) + } +} + func TestAdjudicateDraw(t *testing.T) { cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"} line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...)) diff --git a/internal/server/service/history.go b/internal/server/service/history.go index 53315bc..d67dc4e 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -172,6 +172,7 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game InitialFEN: record.InitialFEN, Result: record.Result, PGNResult: chess.ResultToken(record.Result), + Termination: record.Termination, StartTimeUTC: record.StartTimeUTC, EndTimeUTC: record.EndTimeUTC, MoveCount: record.MoveCount, diff --git a/internal/server/service/history_test.go b/internal/server/service/history_test.go index f850881..779c39d 100644 --- a/internal/server/service/history_test.go +++ b/internal/server/service/history_test.go @@ -142,7 +142,8 @@ func TestUserGamesCursorAndFilters(t *testing.T) { if err != nil { t.Fatal(err) } - if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" { + if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" || + page.Games[0].Termination != "checkmate" { t.Fatalf("finished games = %+v", page.Games) } for _, bad := range []UserGamesOptions{ diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index d46c4f7..630025d 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -1,28 +1,28 @@ -// Game state management -let gameState = { - gameId: null, - fen: null, - turn: 'w', - isPlayerWhite: true, - isLocked: false, - polling: false, - pollController: null, - apiUrl: apiBase(document.currentScript), - selectedSquare: null, - healthCheckInterval: null, - networkError: false, - moveList: [], - authToken: null, - userId: null, - username: null, - authBusy: false, - newGameBusy: false, - accountBusy: false, // logout or account deletion in flight - termination: '', - actionBusy: false, // a draw or resign request is in flight -}; +/* Chess web client: a board for live play against Stockfish, a list of the + signed-in user's stored games, and a replay viewer for any stored game. + Plain JavaScript, one file, no framework: the host's CSP allows only + same-origin scripts, no inline code and no eval. -// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display + Sections: state · utilities · API · auth · board · live game · recent games + on this device · My games drawer · replay · routing · keyboard · init. */ +'use strict'; + +// ---------------------------------------------------------------- state + +// The API base, from the page that loads this script: its element's data-api, +// set by the embedded server from -web-api-url and by a host that publishes +// the files itself. Without it, /chess, where a static host proxies the API. +// Read while the script runs: document.currentScript is null in any callback. +function apiBase(script) { + const api = script?.dataset.api?.trim(); + return api ? api.replace(/\/+$/, '') : '/chess'; +} + +const API = apiBase(document.currentScript); +const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +// Chess piece Unicode: all black pieces for better fill, white pawn due to +// inability to override emoji variant display. const pieceMap = { 'p': '♙', 'r': '♜', 'n': '♞', 'b': '♝', 'q': '♛', 'k': '♚', 'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚' @@ -30,11 +30,97 @@ const pieceMap = { // How long a success message stays visible in a modal before it auto-closes const MODAL_SUCCESS_DISPLAY_MS = 700; +const HEALTH_INTERVAL_MS = 10000; +const GAMES_PAGE_SIZE = 20; +const RECENT_GAMES_KEY = 'chess.recentGames'; +const RECENT_GAMES_MAX = 20; +const RECENT_GAMES_TTL_MS = 24 * 60 * 60 * 1000; // the server's anonymous-game retention -// Shared helpers: show/clear a status line inside a modal. Distinct from -// flashErrorMessage, which is not visible while a modal's backdrop is up. +// The live game played on this board. +const live = { + gameId: null, + initialFen: null, + fen: null, + turn: 'w', + state: null, + termination: '', + moves: [], + san: [], + lastMove: null, + isPlayerWhite: true, + level: 10, + isLocked: false, // the computer is thinking + polling: false, + pollController: null, + selectedSquare: null, + actionBusy: false, // a draw or resign request is in flight + newGameBusy: false, + anonymous: true, // created without an account: listed under "On this device" +}; + +const auth = { + token: null, + userId: null, + username: null, + busy: false, // login or registration in flight + accountBusy: false, // logout or account deletion in flight +}; + +// The stored game being replayed, if any. Separate from the live game: the +// live game stays in memory and is shown again by exitReplay. +const replay = { + active: false, + gameId: null, + history: null, + ply: 0, + flipped: false, + timer: null, // autoplay timeout + speedMs: 1000, + loading: null, // the game ID whose history is being fetched +}; + +// The My games drawer. +const games = { + open: false, + status: '', + color: '', + items: [], + cursor: '', + loading: false, + loadedFor: null, // user ID the list was loaded for +}; + +const ui = { + healthTimer: null, + storage: 'unknown', + toastTimer: null, + historyCache: new Map(), // gameId -> {etag, history} +}; + +const $ = id => document.getElementById(id); + +// ------------------------------------------------------------ utilities + +// A short message under the board; 'error' for failures, 'info' for results +// and answers, 'success' for copies and downloads. +function toast(message, type = 'info', duration = 1800) { + const el = $('toast'); + el.textContent = message; + el.classList.remove('error', 'info', 'success'); + el.classList.add('show', type); + if (ui.toastTimer) clearTimeout(ui.toastTimer); + ui.toastTimer = setTimeout(() => { + el.classList.remove('show'); + ui.toastTimer = null; + }, duration); +} + +function flashErrorMessage(message, duration = 1800) { toast(message, 'error', duration); } + +// Show/clear a status line inside a modal. Distinct from toast, which is not +// visible while a modal's backdrop is up. function setModalMessage(elementId, message, type = 'error') { - const el = document.getElementById(elementId); + const el = $(elementId); if (!el) return; el.textContent = message; el.classList.remove('error', 'success'); @@ -42,76 +128,276 @@ function setModalMessage(elementId, message, type = 'error') { } function clearModalMessage(elementId) { - const el = document.getElementById(elementId); + const el = $(elementId); if (!el) return; el.textContent = ''; el.classList.remove('show', 'error', 'success'); } -// Initialize on page load -document.addEventListener('DOMContentLoaded', () => { - // Check for existing session on load - restoreAuthSession(); +function anyModalOpen() { + return !!document.querySelector('.modal-overlay.show'); +} - document.getElementById('new-game-btn').addEventListener('click', showNewGameModal); - document.getElementById('undo-btn').addEventListener('click', undoMoves); - document.getElementById('draw-btn').addEventListener('click', offerDraw); - document.getElementById('resign-btn').addEventListener('click', showResignModal); - document.getElementById('resign-confirm-btn').addEventListener('click', resignGame); - document.getElementById('resign-cancel-btn').addEventListener('click', hideResignModal); - document.getElementById('start-game-btn').addEventListener('click', startNewGame); - document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal); - document.getElementById('copy-history').addEventListener('click', copyHistory); - - const levelSlider = document.getElementById('computer-level'); - const levelValue = document.getElementById('level-value'); - levelSlider.addEventListener('input', () => { levelValue.textContent = levelSlider.value; }); - - const timeSlider = document.getElementById('search-time'); - const timeValue = document.getElementById('time-value'); - timeSlider.addEventListener('input', () => { timeValue.textContent = timeSlider.value; }); - - startHealthCheck(); - // Don't auto-show modal on load -}); - -document.getElementById('auth-indicator').addEventListener('click', handleAuthClick); -document.querySelectorAll('.auth-tab').forEach(tab => { - tab.addEventListener('click', (e) => switchAuthTab(e.target.dataset.tab)); -}); -document.getElementById('login-submit-btn').addEventListener('click', handleLogin); -document.getElementById('register-submit-btn').addEventListener('click', handleRegister); -document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal); -document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal); -document.getElementById('logout-btn').addEventListener('click', handleLogoutClick); -document.getElementById('account-cancel-btn').addEventListener('click', hideAccountModal); -document.getElementById('delete-account-btn').addEventListener('click', deleteAccount); -document.querySelectorAll('.promotion-choice').forEach(btn => { - btn.addEventListener('click', () => closePromotion(btn.dataset.piece)); -}); -document.getElementById('promotion-cancel-btn').addEventListener('click', () => closePromotion(null)); -document.getElementById('promotion-overlay').addEventListener('click', (e) => { - if (e.target === e.currentTarget) closePromotion(null); -}); - - -// Auth functions -function restoreAuthSession() { - const token = localStorage.getItem('authToken'); - if (token) { - gameState.authToken = token; - validateSession(); +// Copies text; the async clipboard API needs a secure context and, in a +// frame, clipboard-write permission, so a hidden textarea is the fallback. +async function copyText(text) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through } + try { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.className = 'visually-hidden'; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + return ok; + } catch { + return false; + } +} + +// Marks a tool button done for a moment after a copy or download. +function markDone(button) { + button.classList.add('done'); + setTimeout(() => button.classList.remove('done'), 1500); +} + +function formatDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +// A player's display name: the claimant's name snapshot, the engine and its +// level, or "Anonymous". +function playerLabel(player) { + if (!player) return '?'; + if (player.type === 2) return `Stockfish L${player.level ?? 0}`; + return player.name || 'Anonymous'; +} + +// describeResult names a finished game's outcome and how it was reached, from +// a live state ("white wins") or a stored result ("white_wins"). +function describeResult(state, termination) { + const s = (state || '').replace('_', ' '); + const winner = s === 'white wins' ? 'White' : 'Black'; + const loser = s === 'white wins' ? 'Black' : 'White'; + switch (termination) { + case 'resignation': return `${loser} resigned — ${winner} wins`; + case 'checkmate': return `Checkmate — ${winner} wins`; + case 'stalemate': return 'Stalemate — draw'; + case 'insufficient_material': return 'Draw — insufficient material'; + case 'threefold_repetition': return 'Draw — threefold repetition'; + case 'fifty_move_rule': return 'Draw — fifty-move rule'; + case 'agreement': return 'Draw agreed'; + } + switch (s) { + case 'white wins': + case 'black wins': return `${winner} wins`; + case 'stalemate': return 'Stalemate — draw'; + case 'draw': return 'Draw'; + } + return 'Game over'; +} + +function terminationLabel(termination) { + switch (termination) { + case 'checkmate': return 'Checkmate'; + case 'resignation': return 'Resignation'; + case 'stalemate': return 'Stalemate'; + case 'insufficient_material': return 'Insufficient material'; + case 'threefold_repetition': return 'Threefold repetition'; + case 'fifty_move_rule': return 'Fifty-move rule'; + case 'agreement': return 'Agreed'; + } + return ''; +} + +function isGameOver(state) { + return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state); +} + +function isPlayable(state) { + return !isGameOver(state) && state !== 'stuck'; +} + +// ------------------------------------------------------------------ API + +// Wrapper for authenticated requests +function authFetch(url, options = {}) { + if (auth.token) { + options.headers = { ...options.headers, 'Authorization': `Bearer ${auth.token}` }; + } + return fetch(url, options); +} + +function apiJSON(method, path, body) { + const options = { method, headers: {} }; + if (body !== undefined) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(body); + } + return authFetch(`${API}${path}`, options); +} + +// Shared helper: safely parse error response regardless of Content-Type +async function parseErrorResponse(response) { + try { + return await response.json(); + } catch { + return { error: `Server error (${response.status})`, details: null }; + } +} + +// Maps a failed request to a short message and the server indicator state. +function handleApiError(action, error, response = null) { + let serverStatus = 'degraded'; + let statusMessage = 'Server error'; + const isNetworkError = !response; + + if (isNetworkError) { + statusMessage = 'Connection failed'; + console.error(`Network error during ${action}:`, error); + } else { + const status = response.status; + switch (status) { + case 400: + serverStatus = 'healthy'; + if (action === 'undo') statusMessage = 'No moves to undo'; + else if (action === 'move') statusMessage = 'Invalid move'; + else statusMessage = 'Invalid request'; + break; + case 401: + serverStatus = 'healthy'; + statusMessage = 'Sign in again'; + break; + case 403: + serverStatus = 'healthy'; + if (action === 'move' || action === 'trigger computer move' || action === 'undo') { + statusMessage = 'Side claimed by another player'; + } else { + statusMessage = 'Not authorized'; + } + break; + case 404: + serverStatus = 'healthy'; + statusMessage = action === 'history' ? 'Stored game not found' : 'Game not found'; + break; + case 409: + serverStatus = 'healthy'; + statusMessage = 'Game changed; try again'; + break; + case 415: + serverStatus = 'healthy'; + statusMessage = 'Invalid content type'; + break; + case 429: + serverStatus = 'degraded'; + statusMessage = 'Rate limited'; + break; + case 503: + serverStatus = 'degraded'; + statusMessage = action === 'history' || action === 'games' || action === 'pgn' + ? 'Stored games unavailable' : 'Service unavailable'; + break; + default: + if (status >= 500) { + serverStatus = 'degraded'; + statusMessage = `Server error (${status})`; + } else if (status >= 400) { + serverStatus = 'healthy'; + statusMessage = `Request failed (${status})`; + } + } + console.error(`API error during ${action}: ${status} - ${statusMessage}`); + } + + flashErrorMessage(statusMessage); + if (isNetworkError || response.status >= 500) { + setServerStatus(serverStatus, statusMessage); + } else { + setServerStatus('healthy'); + } + + return { + serverStatus, + statusMessage, + isNetworkError, + isClientError: !!response && response.status >= 400 && response.status < 500, + isServerError: !!response && response.status >= 500, + }; +} + +function setDot(id, prefix, status, text) { + const dot = $(id); + dot.dataset.status = status; + const label = `${prefix}: ${text || status}`; + dot.title = label; + dot.setAttribute('aria-label', label); +} + +function setServerStatus(status, message) { + setDot('server-dot', 'Server', status, message); +} + +function setStorageStatus(status) { + ui.storage = status; + setDot('storage-dot', 'Storage', status, status === 'ok' ? 'ok (games are stored)' : + status === 'disabled' ? 'disabled (no stored games)' : status); + updateLiveTools(); +} + +function startHealthCheck() { + const checkHealth = async () => { + try { + const response = await fetch(`${API}/health`); + if (response.ok) { + const health = await response.json(); + setServerStatus(health.status === 'healthy' ? 'healthy' : 'degraded'); + setStorageStatus(health.storage || 'unknown'); + } else { + handleApiError('health check', null, response); + setStorageStatus('unknown'); + } + } catch (error) { + handleApiError('health check', error); + setStorageStatus('unknown'); + } + }; + checkHealth(); + ui.healthTimer = setInterval(checkHealth, HEALTH_INTERVAL_MS); +} + +// ----------------------------------------------------------------- auth + +function restoreAuthSession() { + let token = null; + try { token = localStorage.getItem('authToken'); } catch { /* storage blocked */ } + if (token) { + auth.token = token; + return validateSession(); + } + updateAuthButton(); + return Promise.resolve(); } async function validateSession() { try { - const response = await authFetch(`${gameState.apiUrl}/api/auth/me`); + const response = await authFetch(`${API}/api/auth/me`); if (response.ok) { const user = await response.json(); - gameState.userId = user.userId; - gameState.username = user.username; - updateAuthIndicator(true); + setSignedIn(user.userId, user.username, auth.token); } else { clearAuthState(); } @@ -120,65 +406,67 @@ async function validateSession() { } } -function clearAuthState() { - gameState.authToken = null; - gameState.userId = null; - gameState.username = null; - localStorage.removeItem('authToken'); - updateAuthIndicator(false); +function setSignedIn(userId, username, token) { + auth.token = token; + auth.userId = userId; + auth.username = username; + try { localStorage.setItem('authToken', token); } catch { /* storage blocked */ } + updateAuthButton(); + if (games.open) refreshGamesForUser(); } -function updateAuthIndicator(authenticated) { - const indicator = document.getElementById('auth-indicator'); - const light = indicator.querySelector('.light'); - if (authenticated) { - light.setAttribute('data-status', 'authenticated'); - indicator.setAttribute('data-status', gameState.username); - indicator.setAttribute('data-tooltip', 'Account'); - } else { - light.setAttribute('data-status', 'anonymous'); - indicator.setAttribute('data-status', 'click to login'); - indicator.setAttribute('data-tooltip', 'Login'); - } +function clearAuthState() { + auth.token = null; + auth.userId = null; + auth.username = null; + try { localStorage.removeItem('authToken'); } catch { /* storage blocked */ } + updateAuthButton(); + if (games.open) refreshGamesForUser(); +} + +function updateAuthButton() { + const button = $('auth-btn'); + const signedIn = !!auth.token; + button.dataset.status = signedIn ? 'authenticated' : 'anonymous'; + $('auth-dot').dataset.status = signedIn ? 'authenticated' : 'anonymous'; + $('auth-label').textContent = signedIn ? auth.username : 'Sign in'; + button.title = signedIn ? `Signed in as ${auth.username}` : 'Sign in or register'; + button.setAttribute('aria-label', button.title); } function handleAuthClick() { - if (gameState.authToken) { - showAccountModal(); - } else { - showAuthModal(); - } + if (auth.token) showAccountModal(); else showAuthModal(); } -// Account dialog, for a signed-in user: log out, or delete the account. It +// Account dialog, for a signed-in user: sign out, or delete the account. It // replaces a native confirm(), which a sandboxed embed may not be allowed. function showAccountModal() { - document.getElementById('account-username').textContent = gameState.username || ''; - document.getElementById('delete-account').open = false; - document.getElementById('delete-password').value = ''; + $('account-username').textContent = auth.username || ''; + $('delete-account').open = false; + $('delete-password').value = ''; clearModalMessage('account-modal-message'); - document.getElementById('account-overlay').classList.add('show'); + $('account-overlay').classList.add('show'); document.removeEventListener('keydown', handleAccountKeydown); document.addEventListener('keydown', handleAccountKeydown); - document.getElementById('account-cancel-btn').focus(); + $('account-cancel-btn').focus(); } function hideAccountModal() { - if (gameState.accountBusy) return; - document.getElementById('account-overlay').classList.remove('show'); - document.getElementById('delete-password').value = ''; + if (auth.accountBusy) return; + $('account-overlay').classList.remove('show'); + $('delete-password').value = ''; document.removeEventListener('keydown', handleAccountKeydown); } function setAccountBusy(busy) { - gameState.accountBusy = busy; + auth.accountBusy = busy; for (const id of ['logout-btn', 'account-cancel-btn', 'delete-account-btn', 'delete-password']) { - document.getElementById(id).disabled = busy; + $(id).disabled = busy; } } function handleAccountKeydown(e) { - if (gameState.accountBusy) { + if (auth.accountBusy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } @@ -198,11 +486,22 @@ async function handleLogoutClick() { hideAccountModal(); } +async function handleLogout() { + if (auth.token) { + try { + await apiJSON('POST', '/api/auth/logout'); + } catch { + // Ignore - clear local state regardless + } + } + clearAuthState(); +} + // The server re-checks the password, ends the session, clears the account's // claims and names from its games, then deletes it (DELETE /api/auth/me). async function deleteAccount() { - if (gameState.accountBusy) return; - const password = document.getElementById('delete-password').value; + if (auth.accountBusy) return; + const password = $('delete-password').value; if (!password) { setModalMessage('account-modal-message', 'Enter your password to delete the account', 'error'); return; @@ -212,11 +511,7 @@ async function deleteAccount() { clearModalMessage('account-modal-message'); let response; try { - response = await authFetch(`${gameState.apiUrl}/api/auth/me`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - }); + response = await apiJSON('DELETE', '/api/auth/me', { password }); } catch { setAccountBusy(false); setModalMessage('account-modal-message', 'Network error: the account was not deleted', 'error'); @@ -224,7 +519,7 @@ async function deleteAccount() { } if (response.status === 204) { - const name = gameState.username; + const name = auth.username; clearAuthState(); setModalMessage('account-modal-message', `Account ${name} deleted`, 'success'); setTimeout(() => { @@ -242,7 +537,7 @@ async function deleteAccount() { } else if (response.status === 401) { // The token itself was refused: the session is already gone. clearAuthState(); - message = 'Your session has ended. Log in again to delete the account.'; + message = 'Your session has ended. Sign in again to delete the account.'; } else if (response.status === 429) { message = 'Too many attempts. Wait a minute and try again.'; } else { @@ -251,30 +546,27 @@ async function deleteAccount() { setModalMessage('account-modal-message', message, 'error'); } -// Disables/enables every interactive control in the auth modal at once, and tracks -// whether a login/register request is in flight (or its success message is showing). -// Guards re-entrancy from the Enter-key handler, which bypasses individual button -// disabled state, and stops the user editing fields or switching tabs mid-request. +// Disables every interactive control in the auth modal at once while a +// request is in flight (or its success message is showing): guards +// re-entrancy from the Enter-key handler and stops edits mid-request. function setAuthModalBusy(busy) { - gameState.authBusy = busy; - document.getElementById('login-submit-btn').disabled = busy; - document.getElementById('register-submit-btn').disabled = busy; - document.getElementById('auth-cancel-btn').disabled = busy; - document.getElementById('auth-cancel-btn-2').disabled = busy; + auth.busy = busy; + for (const id of ['login-submit-btn', 'register-submit-btn', 'auth-cancel-btn', 'auth-cancel-btn-2']) { + $(id).disabled = busy; + } document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy); document.querySelectorAll('.auth-form input').forEach(i => i.disabled = busy); } function showAuthModal() { - document.getElementById('auth-modal-overlay').classList.add('show'); - document.getElementById('login-identifier').focus(); - // Remove first to prevent duplicate registrations + $('auth-modal-overlay').classList.add('show'); + $('login-identifier').focus(); document.removeEventListener('keydown', handleAuthModalKeydown); document.addEventListener('keydown', handleAuthModalKeydown); } function hideAuthModal() { - document.getElementById('auth-modal-overlay').classList.remove('show'); + $('auth-modal-overlay').classList.remove('show'); document.querySelectorAll('.auth-form input').forEach(input => input.value = ''); document.removeEventListener('keydown', handleAuthModalKeydown); clearModalMessage('auth-modal-message'); @@ -282,345 +574,339 @@ function hideAuthModal() { } function handleAuthModalKeydown(e) { - const modal = document.getElementById('auth-modal-overlay'); - if (!modal.classList.contains('show')) return; - - // While a request is in flight, block just Enter (re-submit) and Escape - // (close); everything else (Tab, copy shortcuts, etc.) passes through. - if (gameState.authBusy) { + if (!$('auth-modal-overlay').classList.contains('show')) return; + if (auth.busy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } - if (e.key === 'Escape') { e.preventDefault(); hideAuthModal(); } else if (e.key === 'Enter') { e.preventDefault(); - const loginForm = document.getElementById('login-form'); - if (loginForm.style.display !== 'none') { - handleLogin(); - } else { - handleRegister(); - } + if (!$('login-form').classList.contains('hidden')) handleLogin(); else handleRegister(); } } function switchAuthTab(tab) { - document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); - document.querySelector(`[data-tab="${tab}"]`).classList.add('active'); - - document.getElementById('login-form').style.display = tab === 'login' ? 'block' : 'none'; - document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none'; + document.querySelectorAll('.auth-tab').forEach(t => { + const active = t.dataset.tab === tab; + t.classList.toggle('active', active); + t.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + $('login-form').classList.toggle('hidden', tab !== 'login'); + $('register-form').classList.toggle('hidden', tab !== 'register'); clearModalMessage('auth-modal-message'); + $(tab === 'login' ? 'login-identifier' : 'register-username').focus(); } -// Shared helper: safely parse error response regardless of Content-Type -async function parseErrorResponse(response) { - try { - return await response.json(); - } catch { - return { error: `Server error (${response.status})`, details: null }; - } -} - -async function handleLogin() { - if (gameState.authBusy) return; - - const identifier = document.getElementById('login-identifier').value.trim(); - const password = document.getElementById('login-password').value; - - if (!identifier || !password) { - setModalMessage('auth-modal-message', 'Fill all fields', 'error'); - return; - } - +async function submitAuth(path, body, failure) { setAuthModalBusy(true); clearModalMessage('auth-modal-message'); let response; try { - response = await fetch(`${gameState.apiUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, password }) - }); + response = await apiJSON('POST', path, body); } catch (error) { - const errorInfo = handleApiError('login', error); + const errorInfo = handleApiError(path, error); setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error'); setAuthModalBusy(false); - return; + return null; } if (!response.ok) { const err = await parseErrorResponse(response); - setModalMessage('auth-modal-message', err.details || err.error || 'Login failed', 'error'); + setModalMessage('auth-modal-message', err.details || err.error || failure, 'error'); setAuthModalBusy(false); - return; + return null; } - let auth; try { - auth = await response.json(); + return await response.json(); } catch (error) { - console.error('Login: response OK but JSON parse failed:', error); + console.error(`${path}: response OK but JSON parse failed:`, error); setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); setAuthModalBusy(false); + return null; + } +} + +async function handleLogin() { + if (auth.busy) return; + const identifier = $('login-identifier').value.trim(); + const password = $('login-password').value; + if (!identifier || !password) { + setModalMessage('auth-modal-message', 'Fill in both fields', 'error'); return; } - - gameState.authToken = auth.token; - gameState.userId = auth.userId; - gameState.username = auth.username; - localStorage.setItem('authToken', auth.token); - updateAuthIndicator(true); - - setModalMessage('auth-modal-message', `Logged in as ${auth.username}`, 'success'); + const result = await submitAuth('/api/auth/login', { identifier, password }, 'Sign-in failed'); + if (!result) return; + setSignedIn(result.userId, result.username, result.token); + setModalMessage('auth-modal-message', `Signed in as ${result.username}`, 'success'); setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } async function handleRegister() { - if (gameState.authBusy) return; - - const username = document.getElementById('register-username').value.trim(); - const email = document.getElementById('register-email').value.trim(); - const password = document.getElementById('register-password').value; - + if (auth.busy) return; + const username = $('register-username').value.trim(); + const email = $('register-email').value.trim(); + const password = $('register-password').value; if (!username || !password) { setModalMessage('auth-modal-message', 'Username and password required', 'error'); return; } if (password.length < 8) { - setModalMessage('auth-modal-message', 'Password min 8 chars', 'error'); + setModalMessage('auth-modal-message', 'Password needs at least 8 characters', 'error'); return; } if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) { - setModalMessage('auth-modal-message', 'Password needs a letter and number', 'error'); + setModalMessage('auth-modal-message', 'Password needs a letter and a number', 'error'); return; } - - setAuthModalBusy(true); - clearModalMessage('auth-modal-message'); - const body = { username, password }; if (email) body.email = email; - - let response; - try { - response = await fetch(`${gameState.apiUrl}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - } catch (error) { - const errorInfo = handleApiError('register', error); - setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error'); - setAuthModalBusy(false); - return; - } - - if (!response.ok) { - const err = await parseErrorResponse(response); - setModalMessage('auth-modal-message', err.details || err.error || 'Registration failed', 'error'); - setAuthModalBusy(false); - return; - } - - let auth; - try { - auth = await response.json(); - } catch (error) { - console.error('Register: response OK but JSON parse failed:', error); - setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); - setAuthModalBusy(false); - return; - } - - gameState.authToken = auth.token; - gameState.userId = auth.userId; - gameState.username = auth.username; - localStorage.setItem('authToken', auth.token); - updateAuthIndicator(true); - - setModalMessage('auth-modal-message', `Account created, welcome ${auth.username}`, 'success'); + const result = await submitAuth('/api/auth/register', body, 'Registration failed'); + if (!result) return; + setSignedIn(result.userId, result.username, result.token); + setModalMessage('auth-modal-message', `Account created, welcome ${result.username}`, 'success'); setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } -async function handleLogout() { - if (gameState.authToken) { - try { - await authFetch(`${gameState.apiUrl}/api/auth/logout`, { method: 'POST' }); - } catch { - // Ignore - clear local state regardless - } +// ---------------------------------------------------------------- board + +const board = { + flipped: false, + squares: new Map(), // "e4" -> element +}; + +// Builds the 64 squares in the given orientation. The live game and the +// replay share one board; each sets its own orientation when it shows. +function buildBoard(flipped) { + board.flipped = flipped; + board.squares.clear(); + const boardEl = $('board'); + boardEl.innerHTML = ''; + const files = 'abcdefgh'; + const ranks = '87654321'; + const fileLabels = flipped ? [...files].reverse() : [...files]; + const rankLabels = flipped ? [...ranks].reverse() : [...ranks]; + $('board').parentElement.querySelector('.coordinates.files').replaceChildren( + ...fileLabels.map(f => { const s = document.createElement('span'); s.textContent = f; return s; })); + $('board').parentElement.querySelector('.coordinates.ranks').replaceChildren( + ...rankLabels.map(r => { const s = document.createElement('span'); s.textContent = r; return s; })); + + for (let i = 0; i < 64; i++) { + const row = Math.floor(i / 8); // 0 = top of the display + const col = i % 8; + const rank = flipped ? row : 7 - row; // 0 = rank 1 + const file = flipped ? 7 - col : col; // 0 = file a + const name = `${files[file]}${rank + 1}`; + const square = document.createElement('div'); + square.className = `square ${(rank + file) % 2 === 0 ? 'dark' : 'light'}`; + square.dataset.square = name; + square.setAttribute('role', 'gridcell'); + square.setAttribute('aria-label', name); + square.addEventListener('click', handleSquareClick); + boardEl.appendChild(square); + board.squares.set(name, square); } - clearAuthState(); } -// Wrapper for authenticated requests -function authFetch(url, options = {}) { - if (gameState.authToken) { - options.headers = { - ...options.headers, - 'Authorization': `Bearer ${gameState.authToken}` - }; +// Shows a position; the browser never computes moves. opts.lastMove (UCI) +// highlights a move's squares and opts.matedColor marks that king. +function renderPosition(fen, opts = {}) { + const fenBoard = (fen || START_FEN).split(' ')[0]; + for (const s of board.squares.values()) { + s.textContent = ''; + s.classList.remove('white-piece', 'black-piece', 'mated-king', 'last-move-from', 'last-move-to', 'selected'); + delete s.dataset.pieceColor; + delete s.dataset.pieceType; + s.setAttribute('aria-label', s.dataset.square); } - return fetch(url, options); -} -// The API base, from the page that loads this script: its element's data-api, -// set by the embedded server from -web-api-url and by a host that publishes -// the files itself. Without it, /chess, where a static host proxies the API. -// "/" is the origin's root. Read while the script runs: document.currentScript -// is null in any callback. -function apiBase(script) { - const api = script?.dataset.api?.trim(); - return api ? api.replace(/\/+$/, '') : '/chess'; -} - -function startHealthCheck() { - const checkHealth = async () => { - try { - const response = await fetch(`${gameState.apiUrl}/health`); - if (response.ok) { - const health = await response.json(); - updateServerIndicator(health.status === 'healthy' ? 'healthy' : 'degraded'); - updateStorageIndicator(health.storage || 'unknown'); - gameState.networkError = false; - } else { - handleApiError('health check', null, response); - updateStorageIndicator('unknown'); + let rank = 7, file = 0; + for (const char of fenBoard) { + if (char === '/') { + rank--; file = 0; + } else if (/\d/.test(char)) { + file += parseInt(char, 10); + } else { + const name = `${String.fromCharCode(97 + file)}${rank + 1}`; + const squareEl = board.squares.get(name); + if (squareEl) { + const pieceColor = (char === char.toUpperCase()) ? 'w' : 'b'; + squareEl.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || ''; + squareEl.classList.add(pieceColor === 'w' ? 'white-piece' : 'black-piece'); + squareEl.dataset.pieceColor = pieceColor; + squareEl.dataset.pieceType = char.toLowerCase(); + squareEl.setAttribute('aria-label', `${name}: ${pieceColor === 'w' ? 'white' : 'black'} ${pieceName(char)}`); } - } catch (error) { - handleApiError('health check', error); - updateStorageIndicator('unknown'); + file++; } + } + + if (opts.lastMove && opts.lastMove.length >= 4) { + board.squares.get(opts.lastMove.substring(0, 2))?.classList.add('last-move-from'); + board.squares.get(opts.lastMove.substring(2, 4))?.classList.add('last-move-to'); + } + if (opts.matedColor) { + for (const s of board.squares.values()) { + if (s.dataset.pieceType === 'k' && s.dataset.pieceColor === opts.matedColor) { + s.classList.add('mated-king'); + } + } + } +} + +function pieceName(char) { + switch (char.toLowerCase()) { + case 'p': return 'pawn'; + case 'r': return 'rook'; + case 'n': return 'knight'; + case 'b': return 'bishop'; + case 'q': return 'queen'; + case 'k': return 'king'; + } + return ''; +} + +// A thumbnail of a position for the games list: the same glyphs, 8x8 spans. +function renderMiniBoard(container, fen) { + container.innerHTML = ''; + const rows = (fen || START_FEN).split(' ')[0].split('/'); + for (let r = 0; r < 8; r++) { + let file = 0; + for (const char of rows[r] || '8') { + if (/\d/.test(char)) { + const n = parseInt(char, 10); + for (let k = 0; k < n; k++) container.appendChild(miniSquare(r, file++, '')); + } else { + container.appendChild(miniSquare(r, file++, char)); + } + } + while (file < 8) container.appendChild(miniSquare(r, file++, '')); + } +} + +function miniSquare(row, file, char) { + const s = document.createElement('span'); + const rank = 7 - row; + s.className = (rank + file) % 2 === 0 ? 'dark' : 'light'; + if (char) { + s.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || ''; + s.classList.add(char === char.toUpperCase() ? 'white-piece' : 'black-piece'); + } + return s; +} + +function flashSquare(element, success = true) { + if (!element) return; + const className = success ? 'flash-green' : 'flash-red'; + element.classList.add(className); + setTimeout(() => element.classList.remove(className), 400); +} + +// Fills a move grid: number, white move, black move per row. moves[i] is the +// text of ply i+1; startsWithBlack when the first stored ply is Black's. +// onClick, when given, makes each move a button that selects its ply. +function renderMoveGrid(grid, moves, startsWithBlack, currentPly, onClick) { + grid.innerHTML = ''; + let moveNumber = startingMoveNumber(grid); + let i = 0; + const cell = (text, cls, ply) => { + let el; + if (onClick && ply !== undefined) { + el = document.createElement('button'); + el.type = 'button'; + el.addEventListener('click', () => onClick(ply)); + el.setAttribute('aria-label', `Move ${ply}: ${text}`); + } else { + el = document.createElement('div'); + } + el.className = cls; + el.textContent = text; + if (ply !== undefined) { + el.dataset.ply = String(ply); + if (ply === currentPly) el.setAttribute('aria-current', 'true'); + } + return el; }; - - checkHealth(); - gameState.healthCheckInterval = setInterval(checkHealth, 10000); -} - -function updateServerIndicator(status, message = null) { - const indicator = document.getElementById('server-indicator'); - const light = indicator.querySelector('.light'); - light.setAttribute('data-status', status); - // Set custom tooltip if message provided - if (message) { - indicator.setAttribute('data-status', message); - } else { - // Default messages - const defaultMessages = { - 'healthy': 'healthy', - 'degraded': 'degraded', - 'unknown': 'unknown' - }; - indicator.setAttribute('data-status', defaultMessages[status] || status); + if (startsWithBlack && moves.length > 0) { + grid.appendChild(cell(`${moveNumber}.`, 'move-number')); + grid.appendChild(cell('…', 'move-empty')); + grid.appendChild(cell(moves[0], 'move move-black', 1)); + i = 1; + moveNumber++; } -} - -function updateStorageIndicator(status) { - const indicator = document.getElementById('storage-indicator'); - const light = indicator.querySelector('.light'); - light.setAttribute('data-status', status); - indicator.setAttribute('data-status', status); -} - -function updateTurnIndicator(state, turn) { - const indicator = document.getElementById('turn-indicator'); - const light = indicator.querySelector('.light'); - - let status = ''; - let tooltipText = ''; - - if (state === 'pending' || gameState.isLocked) { - status = 'thinking'; - tooltipText = 'Computer Thinking'; - } else if (state && isGameOver(state)) { - switch(state) { - case 'white wins': - status = 'white-wins'; - break; - case 'black wins': - status = 'black-wins'; - break; - case 'stalemate': - status = 'stalemate'; - break; - case 'draw': - status = 'draw'; - break; - default: - status = 'unknown'; + for (; i < moves.length; i += 2) { + grid.appendChild(cell(`${moveNumber}.`, 'move-number')); + grid.appendChild(cell(moves[i], 'move move-white', i + 1)); + if (i + 1 < moves.length) { + grid.appendChild(cell(moves[i + 1], 'move move-black', i + 2)); + } else { + grid.appendChild(cell('', 'move-empty')); } - tooltipText = describeResult(state, gameState.termination); - } else if (state === 'stuck') { - status = 'degraded'; - tooltipText = 'Engine Error'; - } else if (turn === 'w') { - status = 'white'; - tooltipText = 'White'; - } else if (turn === 'b') { - status = 'black'; - tooltipText = 'Black'; - } else { - status = 'unknown'; - tooltipText = 'Unknown'; + moveNumber++; } - - light.setAttribute('data-status', status); - indicator.setAttribute('data-status', tooltipText); } -// Disables/enables every interactive control in the new-game modal at once, and -// tracks whether a create-game request is in flight (or its success message is -// showing). Same rationale as setAuthModalBusy. +// The first move number of a line comes from its initial FEN; a grid carries +// it in data-start so a custom position numbers its moves as PGN does. +function startingMoveNumber(grid) { + const n = parseInt(grid.dataset.start || '1', 10); + return Number.isFinite(n) && n > 0 ? n : 1; +} + +function setGridStart(grid, initialFen) { + const parts = (initialFen || START_FEN).split(' '); + grid.dataset.start = parts[5] || '1'; + return parts[1] === 'b'; +} + +function scrollMoveIntoView(grid, ply) { + const el = grid.querySelector(`[data-ply="${ply}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); + else grid.parentElement.scrollTop = 0; +} + +// ------------------------------------------------------------ live game + +function playerColor() { + return live.isPlayerWhite ? 'w' : 'b'; +} + function setNewGameModalBusy(busy) { - gameState.newGameBusy = busy; - document.getElementById('start-game-btn').disabled = busy; - document.getElementById('cancel-btn').disabled = busy; - document.getElementById('computer-level').disabled = busy; - document.getElementById('search-time').disabled = busy; - document.getElementById('starting-fen').disabled = busy; + live.newGameBusy = busy; + for (const id of ['start-game-btn', 'cancel-btn', 'computer-level', 'search-time', 'starting-fen']) { + $(id).disabled = busy; + } document.querySelectorAll('input[name="player-color"]').forEach(r => r.disabled = busy); } function showNewGameModal() { - const modal = document.getElementById('modal-overlay'); - modal.classList.add('show'); - setupModalKeyboardNav(); + if (replay.active) return; + $('modal-overlay').classList.add('show'); + document.addEventListener('keydown', handleModalKeydown); + $('start-game-btn').focus(); } function hideNewGameModal() { - const modal = document.getElementById('modal-overlay'); - modal.classList.remove('show'); - teardownModalKeyboardNav(); + $('modal-overlay').classList.remove('show'); + document.removeEventListener('keydown', handleModalKeydown); clearModalMessage('new-game-modal-message'); setNewGameModalBusy(false); } -function setupModalKeyboardNav() { - document.addEventListener('keydown', handleModalKeydown); -} - -function teardownModalKeyboardNav() { - document.removeEventListener('keydown', handleModalKeydown); -} - function handleModalKeydown(e) { - const modal = document.getElementById('modal-overlay'); - if (!modal.classList.contains('show')) return; - - // While a request is in flight, block just Enter (re-submit) and Escape - // (close); the color/level/time shortcuts fall through as no-ops since - // those controls are disabled and there's nothing else bound to those keys. - if (gameState.newGameBusy) { + if (!$('modal-overlay').classList.contains('show')) return; + if (live.newGameBusy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } - - switch(e.key) { + const typing = document.activeElement?.id === 'starting-fen'; + switch (e.key) { case 'Enter': + if (typing && e.shiftKey) return; e.preventDefault(); startNewGame(); break; @@ -628,103 +914,51 @@ function handleModalKeydown(e) { e.preventDefault(); hideNewGameModal(); break; - case 'w': - case 'W': + case 'w': case 'W': + if (typing) return; e.preventDefault(); document.querySelector('input[name="player-color"][value="white"]').checked = true; break; - case 'b': - case 'B': + case 'b': case 'B': + if (typing) return; e.preventDefault(); document.querySelector('input[name="player-color"][value="black"]').checked = true; break; - case 'l': - case 'L': + case 'l': case 'L': + if (typing) return; e.preventDefault(); - document.getElementById('computer-level').focus(); + $('computer-level').focus(); break; - case 's': - case 'S': + case 's': case 'S': + if (typing) return; e.preventDefault(); - document.getElementById('search-time').focus(); - break; - case 'ArrowLeft': - handleSliderNav(e, -1); - break; - case 'ArrowRight': - handleSliderNav(e, 1); + $('search-time').focus(); break; } } -function handleSliderNav(e, direction) { - const activeEl = document.activeElement; - if (activeEl.id === 'computer-level') { - e.preventDefault(); - activeEl.value = Math.max(0, Math.min(20, parseInt(activeEl.value) + direction)); - activeEl.dispatchEvent(new Event('input')); - } else if (activeEl.id === 'search-time') { - e.preventDefault(); - activeEl.value = Math.max(100, Math.min(10000, parseInt(activeEl.value) + direction * 100)); - activeEl.dispatchEvent(new Event('input')); - } -} - -function copyHistory() { - const moves = gameState.moveList; - let pgn = ''; - for (let i = 0; i < moves.length; i++) { - if (i % 2 === 0) { - pgn += `${Math.floor(i / 2) + 1}. `; - } - pgn += moves[i] + ' '; - } - - if (gameState.fen) { - pgn += `\n\n[FEN "${gameState.fen}"]`; - } - - navigator.clipboard.writeText(pgn.trim()).then(() => { - const btn = document.getElementById('copy-history'); - btn.classList.add('copied'); - setTimeout(() => { - btn.classList.remove('copied'); - }, 2000); - }); -} - async function startNewGame() { - if (gameState.newGameBusy) return; + if (live.newGameBusy) return; - const playerColor = document.querySelector('input[name="player-color"]:checked').value; - const computerLevel = parseInt(document.getElementById('computer-level').value); - const searchTime = parseInt(document.getElementById('search-time').value); - const startingFEN = document.getElementById('starting-fen').value.trim(); - const willBePlayerWhite = (playerColor === 'white'); - - const whiteConfig = willBePlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime }; - const blackConfig = willBePlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 }; + const chosenColor = document.querySelector('input[name="player-color"]:checked').value; + const computerLevel = parseInt($('computer-level').value, 10); + const searchTime = parseInt($('search-time').value, 10); + const startingFEN = $('starting-fen').value.trim(); + const willBePlayerWhite = chosenColor === 'white'; + const computer = { type: 2, level: computerLevel, searchTime }; const requestBody = { - white: whiteConfig, - black: blackConfig + white: willBePlayerWhite ? { type: 1 } : computer, + black: willBePlayerWhite ? computer : { type: 1 }, }; - - const defaultFEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; - if (startingFEN && startingFEN !== defaultFEN) { - requestBody.fen = startingFEN; - } + if (startingFEN && startingFEN !== START_FEN) requestBody.fen = startingFEN; setNewGameModalBusy(true); clearModalMessage('new-game-modal-message'); let response; try { - response = await authFetch(`${gameState.apiUrl}/api/games`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); + response = await apiJSON('POST', '/api/games', requestBody); } catch (error) { const errorInfo = handleApiError('create game', error); setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error'); @@ -733,8 +967,9 @@ async function startNewGame() { } if (!response.ok) { - const errorInfo = handleApiError('create game', null, response); - setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error'); + const err = await parseErrorResponse(response); + const info = handleApiError('create game', null, response); + setModalMessage('new-game-modal-message', err.details || err.error || info.statusMessage, 'error'); setNewGameModalBusy(false); return; } @@ -749,129 +984,58 @@ async function startNewGame() { return; } - // isPlayerWhite is only committed to global state now that success is confirmed - gameState.isPlayerWhite = willBePlayerWhite; - gameState.gameId = game.gameId; - gameState.moveList = []; - initializeBoard(); + stopPolling(); + live.isPlayerWhite = willBePlayerWhite; + live.gameId = game.gameId; + live.initialFen = game.fen; + live.level = computerLevel; + live.moves = []; + live.san = []; + live.isLocked = false; + live.selectedSquare = null; + live.anonymous = !auth.token; + buildBoard(!live.isPlayerWhite); updateGameDisplay(game); - document.getElementById('undo-btn').disabled = true; - const computerTurn = gameState.isPlayerWhite ? 'b' : 'w'; + const computerTurn = live.isPlayerWhite ? 'b' : 'w'; if (isPlayable(game.state) && game.turn === computerTurn) { triggerComputerMove(); } - setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success'); + setModalMessage('new-game-modal-message', `Game started — you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success'); setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS); } -function initializeBoard() { - const boardEl = document.getElementById('board'); - boardEl.innerHTML = ''; - const isBlackPov = !gameState.isPlayerWhite; - - // Update coordinate labels based on perspective - const topCoords = document.querySelector('.coordinates.top'); - const leftCoords = document.querySelector('.coordinates.left'); - - if (isBlackPov) { - topCoords.innerHTML = 'hgfedcba'; - leftCoords.innerHTML = '12345678'; - } else { - topCoords.innerHTML = 'abcdefgh'; - leftCoords.innerHTML = '87654321'; - } - - for (let i = 0; i < 64; i++) { - const square = document.createElement('div'); - const rank = 7 - Math.floor(i / 8); - const file = i % 8; - const squareName = `${String.fromCharCode(97 + file)}${rank + 1}`; - - const displayRank = isBlackPov ? 7 - rank : rank; - const displayFile = isBlackPov ? 7 - file : file; - - square.className = `square ${(displayRank + displayFile) % 2 === 0 ? 'dark' : 'light'}`; - square.dataset.square = squareName; - square.addEventListener('click', handleSquareClick); - boardEl.appendChild(square); - } -} - -function renderBoardFromFEN(fen) { - const fenBoard = fen.split(' ')[0]; - - // Clear board and remove checkmate indicators - document.querySelectorAll('.square').forEach(s => { - s.textContent = ''; - s.classList.remove('white-piece', 'black-piece', 'mated-king'); - delete s.dataset.pieceColor; - delete s.dataset.pieceType; - }); - - let rank = 7, file = 0; - for (const char of fenBoard) { - if (char === '/') { - rank--; file = 0; - } else if (/\d/.test(char)) { - file += parseInt(char, 10); - } else { - const squareName = `${String.fromCharCode(97 + file)}${rank + 1}`; - const squareEl = document.querySelector(`[data-square="${squareName}"]`); - if (squareEl) { - const pieceColor = (char === char.toUpperCase()) ? 'w' : 'b'; - squareEl.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || ''; - squareEl.classList.add(pieceColor === 'w' ? 'white-piece' : 'black-piece'); - squareEl.dataset.pieceColor = pieceColor; - squareEl.dataset.pieceType = char.toLowerCase(); - } - file++; - } - } -} - function handleSquareClick(e) { - if (gameState.isLocked) return; - - // Block moves after game over - if (!isPlayable(gameState.state)) return; + if (replay.active) return; + if (!live.gameId) { + showNewGameModal(); + return; + } + if (live.isLocked || !isPlayable(live.state)) return; const squareEl = e.currentTarget; const { square, pieceColor } = squareEl.dataset; - const playerTurnColor = gameState.isPlayerWhite ? 'w' : 'b'; + if (live.turn !== playerColor()) return; - if (gameState.turn !== playerTurnColor) return; - - if (gameState.selectedSquare) { - const from = gameState.selectedSquare; - const fromEl = document.querySelector(`[data-square="${from}"]`); - fromEl.classList.remove('selected'); - gameState.selectedSquare = null; - - if (from !== square) { - handleHumanMove(from, square); - } - } else if (pieceColor === playerTurnColor) { - gameState.selectedSquare = square; + if (live.selectedSquare) { + const from = live.selectedSquare; + board.squares.get(from)?.classList.remove('selected'); + live.selectedSquare = null; + if (from !== square) handleHumanMove(from, square); + } else if (pieceColor === playerColor()) { + live.selectedSquare = square; squareEl.classList.add('selected'); - } else { - flashErrorMessage('Invalid Piece Selection'); - // Flash red for invalid piece selection + } else if (pieceColor) { + flashErrorMessage('Not your piece'); flashSquare(squareEl, false); } } -function flashSquare(element, success = true) { - const className = success ? 'flash-green' : 'flash-red'; - element.classList.add(className); - setTimeout(() => element.classList.remove(className), 400); -} - // A pawn stepping or capturing onto its last rank needs a promotion piece; UCI // requires the suffix (e7e8q), and the server rejects the bare move. Other // moves go to the server as-is, which remains the only legality check. function isPromotionMove(from, to) { - const fromEl = document.querySelector(`[data-square="${from}"]`); + const fromEl = board.squares.get(from); if (!fromEl || fromEl.dataset.pieceType !== 'p') return false; const fileDelta = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)); if (fileDelta > 1) return false; @@ -885,7 +1049,7 @@ function isPromotionMove(from, to) { let promotionResolve = null; function choosePromotion(color) { - const overlay = document.getElementById('promotion-overlay'); + const overlay = $('promotion-overlay'); const choices = overlay.querySelector('.promotion-choices'); choices.classList.toggle('white', color === 'w'); choices.classList.toggle('black', color !== 'w'); @@ -896,7 +1060,7 @@ function choosePromotion(color) { } function closePromotion(piece) { - document.getElementById('promotion-overlay').classList.remove('show'); + $('promotion-overlay').classList.remove('show'); document.removeEventListener('keydown', handlePromotionKeydown); const resolve = promotionResolve; promotionResolve = null; @@ -916,8 +1080,8 @@ function handlePromotionKeydown(e) { async function handleHumanMove(from, to) { let move = from + to; - const fromEl = document.querySelector(`[data-square="${from}"]`); - const toEl = document.querySelector(`[data-square="${to}"]`); + const fromEl = board.squares.get(from); + const toEl = board.squares.get(to); if (isPromotionMove(from, to)) { const piece = await choosePromotion(fromEl.dataset.pieceColor); @@ -926,35 +1090,24 @@ async function handleHumanMove(from, to) { } try { - const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ move }) - }); - - const game = await response.json(); + const response = await apiJSON('POST', `/api/games/${live.gameId}/moves`, { move }); if (!response.ok) { - // Handle client errors differently - these aren't network issues if (response.status === 400) { - // Invalid move - flash message and squares - // Handled early, not shown as server error, and bypasses handleApiError - flashErrorMessage('Invalid Move'); + const err = await parseErrorResponse(response); + flashErrorMessage(err.error?.startsWith('promotion') ? 'Choose a promotion piece' : 'Invalid move'); flashSquare(fromEl, false); flashSquare(toEl, false); - renderBoardFromFEN(gameState.fen); + renderLive(); return; } - // Other errors use error handler handleApiError('move', null, response); return; } - + const game = await response.json(); flashSquare(fromEl, true); flashSquare(toEl, true); updateGameDisplay(game); - if (isPlayable(game.state)) { - triggerComputerMove(); - } + if (isPlayable(game.state)) triggerComputerMove(); } catch (error) { handleApiError('move', error); } @@ -963,19 +1116,12 @@ async function handleHumanMove(from, to) { async function triggerComputerMove() { lockBoard(); try { - const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ move: 'cccc' }) - }); - + const response = await apiJSON('POST', `/api/games/${live.gameId}/moves`, { move: 'cccc' }); if (!response.ok) { handleApiError('trigger computer move', null, response); unlockBoard(); return; } - - gameState.networkError = false; startPolling(); } catch (error) { handleApiError('trigger computer move', error); @@ -984,56 +1130,52 @@ async function triggerComputerMove() { } function startPolling() { - gameState.polling = true; + live.polling = true; pollOnce(); } function stopPolling() { - gameState.polling = false; - if (gameState.pollController) { - gameState.pollController.abort(); - gameState.pollController = null; + live.polling = false; + if (live.pollController) { + live.pollController.abort(); + live.pollController = null; } } async function pollOnce() { - if (!gameState.polling || !gameState.gameId) return; + if (!live.polling || !live.gameId || replay.active) return; - const moveCount = (gameState.moveList || []).length; - gameState.pollController = new AbortController(); + const moveCount = live.moves.length; + live.pollController = new AbortController(); try { const response = await authFetch( - `${gameState.apiUrl}/api/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`, - { signal: gameState.pollController.signal } + `${API}/api/games/${live.gameId}?wait=true&moveCount=${moveCount}`, + { signal: live.pollController.signal } ); - - if (!gameState.polling) return; + if (!live.polling) return; if (!response.ok) { - const errorInfo = handleApiError('poll game state', null, response); + handleApiError('poll game state', null, response); if (response.status === 404) { stopPolling(); unlockBoard(); flashErrorMessage('Game no longer exists'); - gameState.gameId = null; + live.gameId = null; + updateGameControls(); return; } - // Retry after delay for transient errors setTimeout(pollOnce, 2000); return; } const game = await response.json(); - gameState.networkError = false; - updateServerIndicator('healthy'); - + setServerStatus('healthy'); if (game.state !== 'pending') { stopPolling(); updateGameDisplay(game); unlockBoard(); } else { - // Still pending, long-poll again pollOnce(); } } catch (error) { @@ -1045,210 +1187,49 @@ async function pollOnce() { } function lockBoard() { - gameState.isLocked = true; - updateTurnIndicator('pending', gameState.turn); + live.isLocked = true; + updateTurnStrip(); updateGameControls(); } function unlockBoard() { - gameState.isLocked = false; - updateTurnIndicator(gameState.state, gameState.turn); + live.isLocked = false; + updateTurnStrip(); updateGameControls(); } async function undoMoves() { - if (gameState.isLocked) return; - - if (!gameState.moveList || gameState.moveList.length < 2) { - console.log('No moves to undo'); - return; - } + if (live.isLocked || replay.active || !live.gameId) return; + if (live.moves.length < 2) return; // A resignation or agreed draw stays on record when play continues. - const conceded = isGameOver(gameState.state) && - (gameState.termination === 'resignation' || gameState.termination === 'agreement'); + const conceded = isGameOver(live.state) && + (live.termination === 'resignation' || live.termination === 'agreement'); try { - const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ count: 2 }) - }); - + const response = await apiJSON('POST', `/api/games/${live.gameId}/undo`, { count: 2 }); if (!response.ok) { - const errorInfo = handleApiError('undo', null, response); - // For client errors like "no moves to undo", don't throw - if (errorInfo.isClientError) { - console.log('Undo failed:', errorInfo.statusMessage); - return; - } - throw new Error(errorInfo.statusMessage); + handleApiError('undo', null, response); + return; } - const game = await response.json(); - gameState.state = game.state; updateGameDisplay(game); if (game.state === 'stuck') { - flashErrorMessage('Engine error — Undo to recover or start a new game'); + flashErrorMessage('Engine error — undo again or start a new game', 3000); } else if (conceded && game.concession) { const what = game.concession.termination === 'agreement' ? 'agreed draw' : 'resignation'; - flashMessage(`Play continues; the ${what} stays on record`, 'info', 3000); + toast(`Play continues; the ${what} stays on record`, 'info', 3000); } } catch (error) { - if (error.message === 'Failed to fetch') { - handleApiError('undo', error); - } + handleApiError('undo', error); } } -function renderMoveHistory(moves) { - const grid = document.getElementById('move-grid'); - grid.innerHTML = ''; - - let startsWithBlack = false; - if (gameState.fen) { - const fenParts = gameState.fen.split(' '); - const moveNum = parseInt(fenParts[5]) || 1; - const activeColor = fenParts[1]; - startsWithBlack = (moveNum === 1 && activeColor === 'b' && moves.length === 0); - } - - for (let i = 0; i < moves.length; i++) { - const isWhiteMove = (i % 2 === 0); - const moveNumber = Math.floor(i / 2) + 1; - - if (i === 0 || i % 2 === 0) { - const numEl = document.createElement('div'); - numEl.className = 'move-number'; - numEl.textContent = moveNumber + '.'; - grid.appendChild(numEl); - - const whiteEl = document.createElement('div'); - if (isWhiteMove && !startsWithBlack) { - whiteEl.className = 'move-white'; - whiteEl.textContent = moves[i]; - } else if (!isWhiteMove && startsWithBlack) { - whiteEl.className = 'move-empty'; - whiteEl.textContent = '...'; - } else { - whiteEl.className = 'move-empty'; - whiteEl.textContent = ''; - } - grid.appendChild(whiteEl); - - const blackEl = document.createElement('div'); - if (i + 1 < moves.length && !startsWithBlack) { - blackEl.className = 'move-black'; - blackEl.textContent = moves[i + 1]; - i++; - } else if (isWhiteMove && startsWithBlack) { - blackEl.className = 'move-black'; - blackEl.textContent = moves[i]; - } else { - blackEl.className = 'move-empty'; - blackEl.textContent = ''; - } - grid.appendChild(blackEl); - } - } - - const historyContainer = document.getElementById('move-history'); - historyContainer.scrollTop = historyContainer.scrollHeight; -} - -function updateGameDisplay(game) { - const wasOver = isGameOver(gameState.state); - gameState.fen = game.fen; - gameState.turn = game.turn; - gameState.state = game.state; - gameState.termination = game.termination || ''; - gameState.moveList = game.moves || []; - - renderBoardFromFEN(game.fen); - updateTurnIndicator(game.state, game.turn); - - // Clear previous checkmate indicators - document.querySelectorAll('.mated-king').forEach(el => { - el.classList.remove('mated-king'); - }); - - // Highlight last move - document.querySelectorAll('.last-move-from, .last-move-to').forEach(el => { - el.classList.remove('last-move-from', 'last-move-to'); - }); - - if (game.lastMove && game.lastMove.move) { - const from = game.lastMove.move.substring(0, 2); - const to = game.lastMove.move.substring(2, 4); - const fromEl = document.querySelector(`[data-square="${from}"]`); - const toEl = document.querySelector(`[data-square="${to}"]`); - if (fromEl) fromEl.classList.add('last-move-from'); - if (toEl) toEl.classList.add('last-move-to'); - } - - // Update move history - renderMoveHistory(game.moves || []); - - // Update undo button - document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2; - - // Mark the mated king; a resignation leaves the board as it was - if ((game.state === 'white wins' || game.state === 'black wins') && - (game.termination || 'checkmate') === 'checkmate') { - markMatedKing(game); - } - - updateGameControls(); - if (!wasOver && isGameOver(game.state)) { - flashMessage(describeResult(game.state, game.termination), 'info', 4000); - } -} - -// describeResult names a finished game's outcome and how it was reached. -function describeResult(state, termination) { - const winner = state === 'white wins' ? 'White' : 'Black'; - const loser = state === 'white wins' ? 'Black' : 'White'; - switch (termination) { - case 'resignation': return `${loser} resigned — ${winner} wins`; - case 'stalemate': return 'Stalemate — draw'; - case 'insufficient_material': return 'Draw — insufficient material'; - case 'threefold_repetition': return 'Draw — threefold repetition'; - case 'fifty_move_rule': return 'Draw — fifty-move rule'; - case 'agreement': return 'Draw agreed'; - } - switch (state) { - case 'white wins': - case 'black wins': return `Checkmate — ${winner} wins`; - case 'stalemate': return 'Stalemate — draw'; - case 'draw': return 'Draw'; - } - return 'Game over'; -} - -// Draw needs a game in progress with the board free; resigning only needs -// an unfinished game (also while the computer thinks or the engine is stuck). -function updateGameControls() { - const hasGame = !!gameState.gameId; - const idle = !gameState.isLocked && !gameState.actionBusy; - document.getElementById('draw-btn').disabled = - !hasGame || !idle || gameState.state !== 'ongoing'; - document.getElementById('resign-btn').disabled = - !hasGame || gameState.actionBusy || isGameOver(gameState.state); -} - -function playerColor() { - return gameState.isPlayerWhite ? 'w' : 'b'; -} - async function offerDraw() { - if (gameState.actionBusy || !gameState.gameId) return; - gameState.actionBusy = true; + if (live.actionBusy || !live.gameId || replay.active) return; + live.actionBusy = true; updateGameControls(); try { - const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/draw`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'offer', color: playerColor() }) - }); + const response = await apiJSON('POST', `/api/games/${live.gameId}/draw`, { action: 'offer', color: playerColor() }); if (response.status === 409) { flashErrorMessage('Make a move before offering again', 2500); return; @@ -1258,28 +1239,26 @@ async function offerDraw() { return; } const game = await response.json(); - if (game.drawOutcome === 'declined') { - flashMessage('Draw declined', 'info', 2500); - } - gameState.actionBusy = false; + if (game.drawOutcome === 'declined') toast('Draw declined', 'info', 2500); + live.actionBusy = false; updateGameDisplay(game); } catch (error) { handleApiError('offer draw', error); } finally { - gameState.actionBusy = false; + live.actionBusy = false; updateGameControls(); } } function showResignModal() { - if (!gameState.gameId || isGameOver(gameState.state)) return; - document.getElementById('resign-overlay').classList.add('show'); + if (!live.gameId || isGameOver(live.state) || replay.active) return; + $('resign-overlay').classList.add('show'); document.addEventListener('keydown', handleResignKeydown); - document.getElementById('resign-cancel-btn').focus(); + $('resign-cancel-btn').focus(); } function hideResignModal() { - document.getElementById('resign-overlay').classList.remove('show'); + $('resign-overlay').classList.remove('show'); document.removeEventListener('keydown', handleResignKeydown); } @@ -1292,152 +1271,933 @@ function handleResignKeydown(e) { async function resignGame() { hideResignModal(); - if (gameState.actionBusy || !gameState.gameId) return; - gameState.actionBusy = true; + if (live.actionBusy || !live.gameId) return; + live.actionBusy = true; updateGameControls(); try { - const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/resign`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ color: playerColor() }) - }); + const response = await apiJSON('POST', `/api/games/${live.gameId}/resign`, { color: playerColor() }); if (!response.ok) { handleApiError('resign', null, response); return; } const game = await response.json(); stopPolling(); // a computer move in flight is discarded by the server - gameState.isLocked = false; - gameState.actionBusy = false; + live.isLocked = false; + live.actionBusy = false; updateGameDisplay(game); } catch (error) { handleApiError('resign', error); } finally { - gameState.actionBusy = false; + live.actionBusy = false; updateGameControls(); } } -function markMatedKing(game) { - // Find and mark the mated king - const matedColor = game.state === 'white wins' ? 'b' : 'w'; - document.querySelectorAll('.square').forEach(square => { - if (square.dataset.pieceType === 'k' && square.dataset.pieceColor === matedColor) { - square.classList.add('mated-king'); - } +// Applies a game response: state, board (unless a replay is showing), move +// list, controls, and a result message when the game just ended. +function updateGameDisplay(game) { + const wasOver = isGameOver(live.state); + live.fen = game.fen; + live.turn = game.turn; + live.state = game.state; + live.termination = game.termination || ''; + live.moves = game.moves || []; + live.san = Array.isArray(game.san) && game.san.length === live.moves.length ? game.san : []; + live.lastMove = game.lastMove?.move || null; + + if (!replay.active) renderLive(); + renderLiveMoves(); + updateTurnStrip(); + updateGameControls(); + if (live.anonymous && live.gameId) recordRecentGame(game); + if (!wasOver && isGameOver(game.state)) { + toast(describeResult(game.state, game.termination), 'info', 4000); + } +} + +// Shows the live position on the shared board. +function renderLive() { + if (board.flipped !== !live.isPlayerWhite) buildBoard(!live.isPlayerWhite); + const mated = (live.state === 'white wins' || live.state === 'black wins') && + (live.termination || 'checkmate') === 'checkmate'; + renderPosition(live.fen, { + lastMove: live.lastMove, + matedColor: mated ? (live.state === 'white wins' ? 'b' : 'w') : null, }); } -function isGameOver(state) { - return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state); -} -function isPlayable(state) { - return !isGameOver(state) && state !== 'stuck'; +function renderLiveMoves() { + const grid = $('live-moves'); + const startsWithBlack = setGridStart(grid, startingFenOfLive()); + const texts = live.moves.map((uci, i) => live.san[i] || uci); + renderMoveGrid(grid, texts, startsWithBlack, live.moves.length, null); + const box = $('live-moves-box'); + box.scrollTop = box.scrollHeight; } -function handleApiError(action, error, response = null) { - let serverStatus = 'degraded'; - let statusMessage = 'Server Error'; - let isNetworkError = !response; +// The position the live game started from; the create response's FEN. +function startingFenOfLive() { + return live.initialFen || START_FEN; +} - if (isNetworkError) { - // Network/connection error - statusMessage = 'Connection Failed'; - console.error(`Network error during ${action}:`, error); - } else if (response) { - const status = response.status; - - // Map status codes to user-friendly messages - switch (status) { - case 400: - // Bad request - not a server issue, game logic error - serverStatus = 'healthy'; // Server is fine, request was invalid - if (action === 'undo') { - statusMessage = 'No Moves to Undo'; - } else if (action === 'move') { - statusMessage = 'Invalid Move'; - } else { - statusMessage = 'Invalid Request'; - } - break; - case 403: - serverStatus = 'healthy'; - if (action === 'move' || action === 'trigger computer move' || action === 'undo') { - statusMessage = 'Slot Claimed'; - } else { - statusMessage = 'Not Authorized'; - } - break; - case 404: - serverStatus = 'healthy'; // Server is fine, game doesn't exist - statusMessage = 'Game Not Found'; - break; - case 429: - serverStatus = 'degraded'; - statusMessage = 'Rate Limited'; - break; - case 415: - serverStatus = 'healthy'; - statusMessage = 'Invalid Content Type'; - break; - case 500: - case 502: - case 503: - serverStatus = 'degraded'; - statusMessage = status === 503 ? 'Service Unavailable' : 'Server Error'; - break; - default: - if (status >= 500) { - serverStatus = 'degraded'; - statusMessage = `Server Error (${status})`; - } else if (status >= 400) { - serverStatus = 'healthy'; - statusMessage = `Request Failed (${status})`; - } - } - - console.error(`API error during ${action}: ${status} - ${statusMessage}`); - } - - flashErrorMessage(statusMessage); - - // Update indicators based on error type - if (isNetworkError || (response && response.status >= 500)) { - updateServerIndicator(serverStatus, statusMessage); - gameState.networkError = true; +function updateTurnStrip() { + const dot = $('turn-dot'); + const text = $('turn-text'); + let status = 'idle', label = 'No game'; + if (!live.gameId) { + status = 'idle'; + label = 'No game'; + } else if (live.state === 'pending' || live.isLocked) { + status = 'thinking'; + label = 'Computer thinking…'; + } else if (isGameOver(live.state)) { + status = live.state === 'white wins' ? 'white-wins' : live.state === 'black wins' ? 'black-wins' : live.state; + label = describeResult(live.state, live.termination); + } else if (live.state === 'stuck') { + status = 'stuck'; + label = 'Engine error — undo or start a new game'; } else { - // For client errors (4xx), server is healthy but request failed - updateServerIndicator('healthy'); - gameState.networkError = false; + status = live.turn === 'w' ? 'white' : 'black'; + const side = live.turn === 'w' ? 'White' : 'Black'; + label = live.turn === playerColor() ? `Your move (${side})` : `Computer to move (${side})`; } + dot.dataset.status = status; + text.textContent = label; + $('turn-strip').title = label; +} +// Draw needs a game in progress with the board free; resigning only needs +// an unfinished game (also while the computer thinks or the engine is stuck). +function updateGameControls() { + const hasGame = !!live.gameId; + const idle = !live.isLocked && !live.actionBusy; + $('undo-btn').disabled = !hasGame || live.isLocked || live.moves.length < 2; + $('draw-btn').disabled = !hasGame || !idle || live.state !== 'ongoing'; + $('resign-btn').disabled = !hasGame || live.actionBusy || isGameOver(live.state); + updateLiveTools(); +} + +function updateLiveTools() { + const hasGame = !!live.gameId; + $('copy-fen-btn').disabled = !hasGame; + $('copy-pgn-btn').disabled = !hasGame; + $('replay-live-btn').disabled = !hasGame || ui.storage !== 'ok'; + $('replay-live-btn').title = ui.storage === 'ok' ? 'Replay this game' : 'Replay needs stored games (storage is off)'; +} + +async function copyLiveFen() { + if (!live.fen) return; + if (await copyText(live.fen)) { + markDone($('copy-fen-btn')); + toast('FEN copied', 'success'); + } else { + flashErrorMessage('Copy failed'); + } +} + +// Copies the stored game's PGN; without storage, the SAN (or UCI) line and +// the current FEN as a plain-text fallback. +async function copyLivePgn() { + if (!live.gameId) return; + let text = null; + try { + const response = await authFetch(`${API}/api/games/${live.gameId}/pgn`); + if (response.ok) text = await response.text(); + } catch { + // fallback below + } + if (!text) text = localPgnText(); + if (await copyText(text)) { + markDone($('copy-pgn-btn')); + toast('PGN copied', 'success'); + } else { + flashErrorMessage('Copy failed'); + } +} + +function localPgnText() { + const startsWithBlack = startingFenOfLive().split(' ')[1] === 'b'; + let number = parseInt(startingFenOfLive().split(' ')[5], 10) || 1; + const parts = []; + live.moves.forEach((uci, i) => { + const text = live.san[i] || uci; + const whiteMove = startsWithBlack ? i % 2 === 1 : i % 2 === 0; + if (i === 0 && startsWithBlack) parts.push(`${number}...`); + else if (whiteMove) parts.push(`${number}.`); + parts.push(text); + if (!whiteMove) number++; + }); + let pgn = parts.join(' '); + if (live.fen) pgn += `\n\n[FEN "${live.fen}"]`; + return pgn.trim(); +} + +// --------------------------------------------- recent games on this device + +// Games created without an account are listed from localStorage, since the +// server lists stored games per account only. The server deletes them 24 +// hours after their last move, so entries expire on the same schedule. +function loadRecentGames() { + let list = []; + try { + list = JSON.parse(localStorage.getItem(RECENT_GAMES_KEY) || '[]'); + } catch { + list = []; + } + if (!Array.isArray(list)) list = []; + const cutoff = Date.now() - RECENT_GAMES_TTL_MS; + return list.filter(g => g && typeof g.gameId === 'string' && (g.lastActivity || 0) > cutoff); +} + +function saveRecentGames(list) { + try { + localStorage.setItem(RECENT_GAMES_KEY, JSON.stringify(list.slice(0, RECENT_GAMES_MAX))); + } catch { + // storage blocked or full: the list is a convenience only + } +} + +function recordRecentGame(game) { + const list = loadRecentGames(); + const existing = list.find(g => g.gameId === game.gameId); + const now = Date.now(); + const entry = { + gameId: game.gameId, + startedAt: existing?.startedAt || now, + lastActivity: now, + initialFen: existing?.initialFen || startingFenOfLive(), + playerColor: live.isPlayerWhite ? 'w' : 'b', + level: live.level, + moveCount: (game.moves || []).length, + finalFen: game.fen, + state: game.state, + termination: game.termination || '', + }; + saveRecentGames([entry, ...list.filter(g => g.gameId !== game.gameId)]); +} + +// A local entry in the shape of a stored-game summary, for the shared row. +function recentToSummary(g) { + const you = { type: 1, name: 'You' }; + const engine = { type: 2, level: g.level }; + const result = isGameOver(g.state) ? g.state.replace(' ', '_') : ''; return { - serverStatus, - statusMessage, - isNetworkError, - isClientError: response && response.status >= 400 && response.status < 500, - isServerError: response && response.status >= 500 + gameId: g.gameId, + initialFen: g.initialFen, + finalFen: g.finalFen, + result, + pgnResult: pgnResultOf(result), + termination: g.termination, + startTimeUtc: new Date(g.startedAt).toISOString(), + moveCount: g.moveCount, + players: { + white: g.playerColor === 'w' ? you : engine, + black: g.playerColor === 'b' ? you : engine, + }, + local: true, }; } -function flashErrorMessage(message, duration = 1500) { - flashMessage(message, 'error', duration); +function pgnResultOf(result) { + switch (result) { + case 'white_wins': return '1-0'; + case 'black_wins': return '0-1'; + case 'draw': + case 'stalemate': return '1/2-1/2'; + } + return '*'; } -// Shows a short message over the status indicators; 'info' for results and -// answers to draw offers, 'error' for failures. -function flashMessage(message, type = 'error', duration = 1500) { - const overlay = document.getElementById('error-flash-overlay'); - const messageEl = document.getElementById('error-flash-message'); +// ------------------------------------------------------- My games drawer - messageEl.textContent = message; - messageEl.classList.toggle('info', type === 'info'); - overlay.classList.add('show'); - - // Clear any pending timeout to avoid premature hide on rapid calls - if (overlay._flashTimeout) clearTimeout(overlay._flashTimeout); - overlay._flashTimeout = setTimeout(() => { - overlay.classList.remove('show'); - overlay._flashTimeout = null; - }, duration); +function openGames({ push = true } = {}) { + if (games.open) return; + games.open = true; + $('games-overlay').classList.add('show'); + document.addEventListener('keydown', handleGamesKeydown); + renderGamesSections(); + if (auth.token) loadGames(true, games.loadedFor === auth.userId); + if (push) pushURL(); + $('games-close').focus(); } + +function closeGames({ push = true } = {}) { + if (!games.open) return; + games.open = false; + $('games-overlay').classList.remove('show'); + document.removeEventListener('keydown', handleGamesKeydown); + if (push) pushURL(); + $('games-btn').focus(); +} + +function handleGamesKeydown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + closeGames(); + } +} + +// The account changed while the drawer is open: reload for the new user, or +// fall back to the device list. +function refreshGamesForUser() { + games.items = []; + games.cursor = ''; + games.loadedFor = null; + renderGamesSections(); + if (auth.token) loadGames(true); +} + +function renderGamesSections() { + const signedIn = !!auth.token; + $('games-signin-note').classList.toggle('hidden', signedIn); + $('games-filters').classList.toggle('hidden', !signedIn); + $('account-games-heading').textContent = 'Your games'; + $('games-list').classList.toggle('hidden', !signedIn); + $('games-more').classList.toggle('hidden', !signedIn || !games.cursor); + if (!signedIn) $('games-status').textContent = ''; + renderLocalGames(); +} + +function setGamesFilter(kind, value) { + document.querySelectorAll(`.seg[data-filter="${kind}"]`).forEach(b => { + b.classList.toggle('active', b.dataset.value === value); + }); + games[kind] = value; + loadGames(true); +} + +// Loads the first page (reset) or the next one. keep leaves the current +// rows in place until the fresh page arrives, for a reopened drawer. +async function loadGames(reset, keep = false) { + if (!auth.token || games.loading) return; + if (reset) { + games.cursor = ''; + if (!keep) { + games.items = []; + renderGamesList(); + } + } + games.loading = true; + games.loadedFor = auth.userId; + const status = $('games-status'); + status.classList.remove('error'); + status.textContent = 'Loading…'; + $('games-more').disabled = true; + + const query = new URLSearchParams({ limit: String(GAMES_PAGE_SIZE) }); + if (games.status) query.set('status', games.status); + if (games.color) query.set('color', games.color); + if (!reset && games.cursor) query.set('cursor', games.cursor); + + let response; + try { + response = await authFetch(`${API}/api/users/me/games?${query}`); + } catch { + games.loading = false; + status.classList.add('error'); + status.textContent = 'Connection failed. '; + appendRetry(status, () => loadGames(reset)); + $('games-more').disabled = false; + return; + } + games.loading = false; + $('games-more').disabled = false; + + if (response.status === 401) { + clearAuthState(); + return; + } + if (!response.ok) { + const info = handleApiError('games', null, response); + status.classList.add('error'); + status.textContent = `${info.statusMessage}. `; + appendRetry(status, () => loadGames(reset)); + return; + } + + let page; + try { + page = await response.json(); + } catch { + status.classList.add('error'); + status.textContent = 'Unexpected response from server'; + return; + } + games.items = reset ? (page.games || []) : games.items.concat(page.games || []); + games.cursor = page.nextCursor || ''; + status.textContent = ''; + renderGamesList(); +} + +function appendRetry(status, action) { + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'btn btn-ghost btn-sm'; + retry.textContent = 'Retry'; + retry.addEventListener('click', action); + status.appendChild(retry); +} + +function renderGamesList() { + const list = $('games-list'); + list.innerHTML = ''; + for (const item of games.items) list.appendChild(renderGameRow(item)); + const status = $('games-status'); + if (!games.loading && games.items.length === 0 && auth.token) { + status.classList.remove('error'); + status.textContent = games.status || games.color ? 'No games match these filters.' : + 'No stored games yet. Finish a move in a new game and it will appear here.'; + } + $('games-more').classList.toggle('hidden', !games.cursor); +} + +function renderLocalGames() { + const entries = loadRecentGames(); + const section = $('local-games-section'); + section.classList.toggle('hidden', entries.length === 0); + const list = $('local-games-list'); + list.innerHTML = ''; + for (const entry of entries) list.appendChild(renderGameRow(recentToSummary(entry))); +} + +// One game in the list: thumbnail, players, outcome, date, and a replay +// affordance. The whole row is a button. +function renderGameRow(summary) { + const li = document.createElement('li'); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'game-row'; + if (summary.gameId === live.gameId) row.classList.add('current'); + row.dataset.gameId = summary.gameId; + + const mini = document.createElement('div'); + mini.className = 'mini-board'; + mini.setAttribute('aria-hidden', 'true'); + renderMiniBoard(mini, summary.finalFen); + + const info = document.createElement('div'); + info.className = 'game-info'; + const players = document.createElement('div'); + players.className = 'game-players'; + players.textContent = `${sideLabel(summary.players?.white)} vs ${sideLabel(summary.players?.black)}`; + const outcome = document.createElement('div'); + outcome.className = 'game-outcome'; + const token = document.createElement('span'); + token.className = 'pgn-result'; + const ongoing = !summary.result; + token.textContent = ongoing ? '*' : (summary.pgnResult || pgnResultOf(summary.result)); + outcome.appendChild(token); + outcome.appendChild(document.createTextNode(' ')); + outcome.classList.toggle('ongoing', ongoing); + const moves = Math.ceil((summary.moveCount || 0) / 2); + outcome.appendChild(document.createTextNode(ongoing + ? `Ongoing · ${moves} move${moves === 1 ? '' : 's'}` + : `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`)); + const when = document.createElement('div'); + when.className = 'game-when'; + when.textContent = formatDate(summary.startTimeUtc) + (summary.gameId === live.gameId ? ' · this game' : ''); + info.append(players, outcome, when); + + const go = document.createElement('span'); + go.className = 'game-go'; + go.setAttribute('aria-hidden', 'true'); + go.innerHTML = ''; + + row.append(mini, info, go); + row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`); + row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0)); + li.appendChild(row); + return li; +} + +// A side's name in the list, "You" for the signed-in user's claim. +function sideLabel(player) { + if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You'; + return playerLabel(player); +} + +// --------------------------------------------------------------- replay + +function isUUID(s) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s || ''); +} + +// Fetches a stored game's history, revalidating a cached copy with its ETag. +async function fetchHistory(gameId) { + const cached = ui.historyCache.get(gameId); + const headers = {}; + if (cached?.etag) headers['If-None-Match'] = cached.etag; + const response = await authFetch(`${API}/api/games/${gameId}/history`, { headers }); + if (response.status === 304 && cached) return cached.history; + if (!response.ok) { + const info = handleApiError('history', null, response); + throw new Error(info.statusMessage); + } + const history = await response.json(); + ui.historyCache.set(gameId, { etag: response.headers.get('ETag'), history }); + return history; +} + +// Opens a stored game at a ply. The live game is suspended: its polling +// stops and its controls hide until exitReplay. +async function enterReplay(gameId, ply = 0, { push = true } = {}) { + if (!isUUID(gameId)) { + flashErrorMessage('Invalid game ID'); + return; + } + stopAutoplay(); + if (!replay.active) { + stopPolling(); + replay.active = true; + document.body.dataset.mode = 'replay'; + for (const s of board.squares.values()) s.classList.remove('selected'); + live.selectedSquare = null; + } + if (games.open) closeGames({ push: false }); + replay.loading = gameId; + replay.gameId = gameId; + replay.history = null; + replay.ply = 0; + renderReplayLoading(); + if (push) pushURL(); else replaceURL(); + + let history; + try { + history = await fetchHistory(gameId); + } catch (error) { + if (replay.loading !== gameId) return; // superseded + replay.loading = null; + renderReplayError(error.message || 'Could not load the game'); + return; + } + if (replay.loading !== gameId || !replay.active) return; + replay.loading = null; + replay.history = history; + replay.flipped = defaultFlip(history); + buildBoard(replay.flipped); + $('replay-flip').setAttribute('aria-pressed', replay.flipped ? 'true' : 'false'); + renderReplayHead(); + renderReplayMoves(); + gotoPly(ply, { announce: false }); +} + +// Black at the bottom when the viewer claimed Black, or when Black is the +// only human side (a computer took White). +function defaultFlip(history) { + const white = history.players?.white, black = history.players?.black; + if (auth.userId) { + if (black?.claimedBy === auth.userId) return true; + if (white?.claimedBy === auth.userId) return false; + } + if (history.gameId === live.gameId) return !live.isPlayerWhite; + return white?.type === 2 && black?.type !== 2; +} + +function exitReplay({ push = true } = {}) { + if (!replay.active) return; + stopAutoplay(); + replay.active = false; + replay.loading = null; + replay.gameId = null; + replay.history = null; + replay.ply = 0; + document.body.dataset.mode = 'live'; + buildBoard(!live.isPlayerWhite); + if (live.gameId) renderLive(); else renderPosition(START_FEN); + $('ply-announcer').textContent = ''; + if (push) pushURL(); + // A computer move may have completed meanwhile. + if (live.gameId && live.isLocked) startPolling(); + updateGameControls(); +} + +function renderReplayLoading() { + $('replay-result').textContent = ''; + $('replay-ply').textContent = ''; + $('replay-players').textContent = 'Loading game…'; + $('replay-meta').textContent = replay.gameId || ''; + const grid = $('replay-moves'); + grid.innerHTML = ''; + $('replay-moves-empty').textContent = 'Loading…'; + setReplayNavEnabled(false); + $('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay'; +} + +function renderReplayError(message) { + $('replay-players').textContent = message; + $('replay-meta').textContent = replay.gameId || ''; + $('replay-moves-empty').textContent = 'The stored game could not be loaded. It may have been deleted, or storage is off.'; + renderPosition(START_FEN); + setReplayNavEnabled(false); +} + +function setReplayNavEnabled(enabled) { + for (const id of ['replay-first', 'replay-prev', 'replay-play', 'replay-next', 'replay-last', + 'replay-flip', 'replay-copy-fen', 'replay-copy-pgn', 'replay-download', 'replay-link', 'replay-speed']) { + $(id).disabled = !enabled; + } +} + +function renderReplayHead() { + const h = replay.history; + const finished = !!h.result; + $('replay-result').textContent = finished + ? `${h.pgnResult} · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}` + : 'Ongoing'; + $('replay-players').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`; + $('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`; + const plies = h.moves.length; + const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`]; + if (h.concession && h.concession.termination) { + meta.push(h.concession.termination === 'agreement' + ? `draw agreed at ply ${h.concession.ply}` + : `resignation at ply ${h.concession.ply}`); + } + $('replay-meta').textContent = meta.join(' · '); + $('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay'; + setReplayNavEnabled(true); +} + +function renderReplayMoves() { + const h = replay.history; + const grid = $('replay-moves'); + const startsWithBlack = setGridStart(grid, h.initialFen); + const texts = h.moves.map(m => m.san || m.moveUci); + renderMoveGrid(grid, texts, startsWithBlack, replay.ply, ply => { stopAutoplay(); gotoPly(ply); }); + $('replay-moves-empty').textContent = 'No moves were played.'; +} + +// Shows the position after n plies; the stored FEN is assigned as is. +function gotoPly(n, { announce = true } = {}) { + const h = replay.history; + if (!h) return; + const last = h.moves.length; + n = Math.max(0, Math.min(last, Number.isFinite(n) ? n : 0)); + replay.ply = n; + const fen = n === 0 ? h.initialFen : h.moves[n - 1].fenAfterMove; + const lastMove = n > 0 ? h.moves[n - 1].moveUci : null; + let matedColor = null; + if (n === last && h.termination === 'checkmate') { + matedColor = h.result === 'white_wins' ? 'b' : 'w'; + } + renderPosition(fen, { lastMove, matedColor }); + + const grid = $('replay-moves'); + grid.querySelectorAll('[aria-current]').forEach(el => el.removeAttribute('aria-current')); + const current = grid.querySelector(`[data-ply="${n}"]`); + if (current) current.setAttribute('aria-current', 'true'); + scrollMoveIntoView(grid, n); + + $('replay-ply').textContent = `${n} / ${last}`; + $('replay-first').disabled = n === 0; + $('replay-prev').disabled = n === 0; + $('replay-next').disabled = n === last; + $('replay-last').disabled = n === last; + $('replay-play').disabled = last === 0; + + if (announce) $('ply-announcer').textContent = plyLabel(n); + replaceURL(); +} + +function plyLabel(n) { + const h = replay.history; + if (!h || n === 0) return 'Start position'; + const move = h.moves[n - 1]; + const startsWithBlack = (h.initialFen || START_FEN).split(' ')[1] === 'b'; + const first = parseInt((h.initialFen || START_FEN).split(' ')[5], 10) || 1; + const number = first + Math.floor((n - 1 + (startsWithBlack ? 1 : 0)) / 2); + const dots = move.playerColor === 'b' ? '…' : '.'; + return `Ply ${n} of ${h.moves.length}: ${number}${dots} ${move.san || move.moveUci}`; +} + +function stepReplay(delta) { + if (!replay.history) return; + gotoPly(replay.ply + delta); +} + +function toggleAutoplay() { + if (replay.timer) { + stopAutoplay(); + return; + } + if (!replay.history || replay.history.moves.length === 0) return; + if (replay.ply >= replay.history.moves.length) gotoPly(0); + $('replay-play').setAttribute('aria-pressed', 'true'); + $('replay-play').setAttribute('aria-label', 'Pause'); + scheduleAutoplay(); +} + +function scheduleAutoplay() { + replay.timer = setTimeout(() => { + replay.timer = null; + if (!replay.active || !replay.history) return; + gotoPly(replay.ply + 1); + if (replay.ply >= replay.history.moves.length) { + stopAutoplay(); + return; + } + scheduleAutoplay(); + }, replay.speedMs); +} + +function stopAutoplay() { + if (replay.timer) { + clearTimeout(replay.timer); + replay.timer = null; + } + $('replay-play').setAttribute('aria-pressed', 'false'); + $('replay-play').setAttribute('aria-label', 'Autoplay'); +} + +function flipReplayBoard() { + if (!replay.history) return; + replay.flipped = !replay.flipped; + $('replay-flip').setAttribute('aria-pressed', replay.flipped ? 'true' : 'false'); + buildBoard(replay.flipped); + gotoPly(replay.ply, { announce: false }); +} + +function replayFen() { + const h = replay.history; + return replay.ply === 0 ? h.initialFen : h.moves[replay.ply - 1].fenAfterMove; +} + +function replayPgnPath() { + const h = replay.history; + const full = replay.ply === h.moves.length; + return `/api/games/${replay.gameId}/pgn${full ? '' : `?ply=${replay.ply}`}`; +} + +async function copyReplayFen() { + if (!replay.history) return; + if (await copyText(replayFen())) { + markDone($('replay-copy-fen')); + toast('FEN copied', 'success'); + } else { + flashErrorMessage('Copy failed'); + } +} + +async function fetchReplayPgn() { + const response = await authFetch(`${API}${replayPgnPath()}`); + if (!response.ok) { + handleApiError('pgn', null, response); + return null; + } + const disposition = response.headers.get('Content-Disposition') || ''; + const match = /filename="([^"]+)"/.exec(disposition); + return { text: await response.text(), filename: match ? match[1] : `chess-${replay.gameId.slice(0, 8)}.pgn` }; +} + +async function copyReplayPgn() { + if (!replay.history) return; + let pgn; + try { + pgn = await fetchReplayPgn(); + } catch (error) { + handleApiError('pgn', error); + return; + } + if (!pgn) return; + if (await copyText(pgn.text)) { + markDone($('replay-copy-pgn')); + toast('PGN copied', 'success'); + } else { + flashErrorMessage('Copy failed'); + } +} + +// Saves the PGN through a temporary link to a blob: URL. In a sandboxed frame +// this needs the allow-downloads flag on the host's iframe. +async function downloadReplayPgn() { + if (!replay.history) return; + let pgn; + try { + pgn = await fetchReplayPgn(); + } catch (error) { + handleApiError('pgn', error); + return; + } + if (!pgn) return; + const blob = new Blob([pgn.text], { type: 'application/x-chess-pgn' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = pgn.filename; + a.className = 'visually-hidden'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + markDone($('replay-download')); + toast(`Saved ${pgn.filename}`, 'success'); +} + +async function copyReplayLink() { + if (!replay.gameId) return; + const link = `${location.origin}${location.pathname}?replay=${replay.gameId}&ply=${replay.ply}`; + if (await copyText(link)) { + markDone($('replay-link')); + toast('Link copied', 'success'); + } else { + flashErrorMessage('Copy failed'); + } +} + +// -------------------------------------------------------------- routing + +// The URL names the view: ?replay=&ply= for a replay, with &games +// while the drawer is open. Views push history entries; stepping through a +// replay replaces the entry, so back and forward move between views. +function urlForState() { + const parts = []; + if (replay.active && replay.gameId) { + parts.push(`replay=${encodeURIComponent(replay.gameId)}`, `ply=${replay.ply}`); + } + if (games.open) parts.push('games'); + return location.pathname + (parts.length ? `?${parts.join('&')}` : ''); +} + +function pushURL() { + try { + if (urlForState() !== location.pathname + location.search) history.pushState({ chess: true }, '', urlForState()); + } catch { /* history blocked */ } +} + +function replaceURL() { + try { + history.replaceState({ chess: true }, '', urlForState()); + } catch { /* history blocked */ } +} + +// Brings the page to what the URL names, without touching history. +async function applyURL() { + const params = new URLSearchParams(location.search); + const id = params.get('replay'); + const ply = parseInt(params.get('ply') || '0', 10); + const wantGames = params.has('games'); + + if (id && isUUID(id)) { + if (!replay.active || replay.gameId !== id) { + await enterReplay(id, ply, { push: false }); + } else if (replay.history) { + stopAutoplay(); + gotoPly(ply, { announce: false }); + } + } else if (replay.active) { + exitReplay({ push: false }); + } + if (wantGames && !games.open) openGames({ push: false }); + else if (!wantGames && games.open) closeGames({ push: false }); +} + +// ------------------------------------------------------------- keyboard + +function handleGlobalKeydown(e) { + if (anyModalOpen()) return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (!replay.active || !replay.history) return; + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); stopAutoplay(); stepReplay(-1); break; + case 'ArrowRight': + e.preventDefault(); stopAutoplay(); stepReplay(1); break; + case 'Home': + e.preventDefault(); stopAutoplay(); gotoPly(0); break; + case 'End': + e.preventDefault(); stopAutoplay(); gotoPly(replay.history.moves.length); break; + case ' ': + e.preventDefault(); toggleAutoplay(); break; + case 'f': case 'F': + e.preventDefault(); flipReplayBoard(); break; + case 'Escape': + e.preventDefault(); exitReplay(); break; + } +} + +// ----------------------------------------------------------------- init + +function bindEvents() { + $('new-game-btn').addEventListener('click', showNewGameModal); + $('undo-btn').addEventListener('click', undoMoves); + $('draw-btn').addEventListener('click', offerDraw); + $('resign-btn').addEventListener('click', showResignModal); + $('resign-confirm-btn').addEventListener('click', resignGame); + $('resign-cancel-btn').addEventListener('click', hideResignModal); + $('start-game-btn').addEventListener('click', startNewGame); + $('cancel-btn').addEventListener('click', hideNewGameModal); + $('copy-fen-btn').addEventListener('click', copyLiveFen); + $('copy-pgn-btn').addEventListener('click', copyLivePgn); + $('replay-live-btn').addEventListener('click', () => { + if (live.gameId) enterReplay(live.gameId, live.moves.length); + }); + + const levelSlider = $('computer-level'); + levelSlider.addEventListener('input', () => { $('level-value').textContent = levelSlider.value; }); + const timeSlider = $('search-time'); + timeSlider.addEventListener('input', () => { $('time-value').textContent = timeSlider.value; }); + + $('auth-btn').addEventListener('click', handleAuthClick); + document.querySelectorAll('.auth-tab').forEach(tab => { + tab.addEventListener('click', () => switchAuthTab(tab.dataset.tab)); + }); + $('login-submit-btn').addEventListener('click', handleLogin); + $('register-submit-btn').addEventListener('click', handleRegister); + $('auth-cancel-btn').addEventListener('click', hideAuthModal); + $('auth-cancel-btn-2').addEventListener('click', hideAuthModal); + $('logout-btn').addEventListener('click', handleLogoutClick); + $('account-cancel-btn').addEventListener('click', hideAccountModal); + $('delete-account-btn').addEventListener('click', deleteAccount); + document.querySelectorAll('.promotion-choice').forEach(btn => { + btn.addEventListener('click', () => closePromotion(btn.dataset.piece)); + }); + $('promotion-cancel-btn').addEventListener('click', () => closePromotion(null)); + $('promotion-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closePromotion(null); + }); + + $('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); }); + $('games-close').addEventListener('click', () => closeGames()); + $('games-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeGames(); + }); + $('games-more').addEventListener('click', () => loadGames(false)); + document.querySelectorAll('.seg').forEach(seg => { + seg.addEventListener('click', () => setGamesFilter(seg.dataset.filter, seg.dataset.value)); + }); + + $('replay-first').addEventListener('click', () => { stopAutoplay(); gotoPly(0); }); + $('replay-prev').addEventListener('click', () => { stopAutoplay(); stepReplay(-1); }); + $('replay-next').addEventListener('click', () => { stopAutoplay(); stepReplay(1); }); + $('replay-last').addEventListener('click', () => { stopAutoplay(); gotoPly(replay.history?.moves.length || 0); }); + $('replay-play').addEventListener('click', toggleAutoplay); + $('replay-speed').addEventListener('change', () => { + replay.speedMs = parseInt($('replay-speed').value, 10) || 1000; + if (replay.timer) { + clearTimeout(replay.timer); + replay.timer = null; + scheduleAutoplay(); + } + }); + $('replay-flip').addEventListener('click', flipReplayBoard); + $('replay-copy-fen').addEventListener('click', copyReplayFen); + $('replay-copy-pgn').addEventListener('click', copyReplayPgn); + $('replay-download').addEventListener('click', downloadReplayPgn); + $('replay-link').addEventListener('click', copyReplayLink); + $('replay-exit').addEventListener('click', () => exitReplay()); + + document.addEventListener('keydown', handleGlobalKeydown); + window.addEventListener('popstate', () => { applyURL(); }); +} + +document.addEventListener('DOMContentLoaded', async () => { + bindEvents(); + buildBoard(false); + renderPosition(START_FEN); + updateTurnStrip(); + updateGameControls(); + startHealthCheck(); + await restoreAuthSession(); + await applyURL(); +}); diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html index 44d3311..3e205b0 100644 --- a/internal/server/webserver/chess-client-web/index.html +++ b/internal/server/webserver/chess-client-web/index.html @@ -3,86 +3,194 @@ - Chess Game + + Chess - -
-
-
-
-
-
-
- abcd - efgh -
-
- 8765 - 4321 -
-
-
+ +
+
+
Chess
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ +
+
- -
+ + diff --git a/internal/server/webserver/chess-client-web/style.css b/internal/server/webserver/chess-client-web/style.css index 4b88e73..efb730a 100644 --- a/internal/server/webserver/chess-client-web/style.css +++ b/internal/server/webserver/chess-client-web/style.css @@ -1,133 +1,292 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +/* Chess web client. Dark theme matching lixen.com. The board colours and + piece glyph treatment are fixed (see pieceMap in app.js); everything else is + open. Styles live here: the host's CSP has style-src 'unsafe-inline', but + state is expressed through classes and data attributes, never inline style. + + Two layouts share the markup: board beside the panel from 979px up, and + board over the panel below (the host's embed frame switches its height at + the same width). Both must fit the host's frame: 30rem tall side by side, + 46rem (capped at 80svh) stacked. */ + +*, *::before, *::after { box-sizing: border-box; } :root { - /* Host-site dark theme colors */ - --host-bg: #11111b; - --host-surface: #1a1b26; - --host-royal: #5f57f5; - --host-royal-secondary: #38348f; - --host-blue-primary: #2563eb; - --host-blue-secondary: #1e40af; - --host-white: #ffffff; - --blue-accent: #dbeafe; - --host-gray-muted: #64748b; - --host-gray-light: #f8fafc; + /* Host-site palette */ + --bg: #11111b; + --surface: #1a1b26; + --surface-2: #20222f; + --surface-3: #262a3d; + --line: #2d3148; + --line-strong: #3b4261; + --fg: #c0caf5; + --fg-soft: #a9b1d6; + --muted: #7982a9; + --royal: #5f57f5; + --royal-soft: #38348f; + --periwinkle: #8ea2ff; + --blue: #2563eb; + --blue-deep: #1e40af; + --white: #ffffff; + --cyan: #7dcfff; + --green: #9ece6a; + --yellow: #e0af68; + --red: #f7768e; - /* Tokyo Night colors */ - --tokyo-cyan: #7dcfff; - --tokyo-green: #9ece6a; - --tokyo-yellow: #e0af68; - --tokyo-red: #f7768e; - --tokyo-border: #3b4261; - --tokyo-fg: #a9b1d6; - - /* Board colors */ + /* Board (fixed) */ --square-light: #f0d9b5; --square-dark: #b58863; --square-selected: #6a994e; --move-from: #5090d3; --move-to: #81b3f0; --checkmated-king: #8b0000; + + --radius: 8px; + --radius-sm: 6px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + + /* Side-by-side sizes: the board edge includes its coordinate margin. It + grows with the window but never past what is left under the top bar + (the host's 30rem frame leaves 416px), so the embed keeps a 360px + board while a full window gets up to 560px. */ + --topbar-height: 2.5rem; + --board-edge: clamp(360px, min(32vw, calc(100vh - 64px)), 560px); + --board-pad: clamp(16px, 1.04vw, 20px); + --panel-width: clamp(280px, 20vw, 380px); } -/* Base Layout */ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--host-bg); - height: 100vh; - width: 100vw; +html, body { margin: 0; - display: flex; - justify-content: center; - align-items: center; - overflow: hidden; + padding: 0; + height: 100%; } -.outer-container { - width: 100%; - height: 100vh; +body { + font-family: var(--font); + font-size: 15px; + line-height: 1.4; + color: var(--fg-soft); + background: var(--bg); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } + +.visually-hidden { + position: absolute !important; + width: 1px; height: 1px; + margin: -1px; padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.hidden { display: none !important; } + +.icon { + width: 1.1em; + height: 1.1em; + flex: none; +} + +/* --- Frame ----------------------------------------------------------- */ + +.app { + height: 100%; padding: 8px; display: flex; - justify-content: center; - align-items: center; - overflow: hidden; -} - -.container { - background: var(--host-surface); - border-radius: 12px; - box-shadow: 0 20px 40px rgba(0,0,0,0.4); - padding: 1.5rem; - width: calc(100% - 16px); - height: calc(100% - 16px); - color: var(--tokyo-fg); - display: flex; flex-direction: column; + gap: 8px; overflow: hidden; } -main { - display: grid; - grid-template-columns: 1fr clamp(280px, 17.71vw, 340px); - gap: clamp(1rem, 2vw, 2rem); +.topbar { + flex: none; + height: var(--topbar-height); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 0.75rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.brand { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--fg); + font-weight: 600; + letter-spacing: 0.01em; +} + +.brand-mark { + font-size: 1.35rem; + line-height: 1; + color: var(--periwinkle); +} + +.health { + display: flex; + align-items: center; + gap: 0.35rem; + padding-left: 0.5rem; + border-left: 1px solid var(--line); +} + +.topbar-spacer { flex: 1; } + +/* Status dots: colour by data-status. */ +.dot { + display: inline-block; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--line-strong); + flex: none; + transition: background 0.2s; +} + +.dot[data-status="healthy"], .dot[data-status="ok"], .dot[data-status="authenticated"] { background: var(--green); } +.dot[data-status="disabled"], .dot[data-status="anonymous"] { background: var(--yellow); } +.dot[data-status="degraded"], .dot[data-status="error"] { background: var(--red); } +.dot[data-status="unknown"], .dot[data-status="idle"] { background: var(--line-strong); } + +/* --- Buttons --------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.55rem 0.8rem; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 500; + line-height: 1.1; + color: var(--white); + background: var(--surface-3); + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { transform: translateY(-1px); } +.btn:focus-visible { outline: 2px solid var(--periwinkle); outline-offset: 2px; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-primary { background: var(--blue); } +.btn-primary:hover:not(:disabled) { background: var(--royal); } + +.btn-secondary { background: var(--blue-deep); } +.btn-secondary:hover:not(:disabled) { background: var(--royal); } + +.btn-danger { background: var(--red); color: var(--bg); } +.btn-danger:hover:not(:disabled) { filter: brightness(1.1); } + +.btn-ghost { + background: transparent; + border-color: var(--line); + color: var(--fg-soft); +} +.btn-ghost:hover:not(:disabled) { border-color: var(--royal); background: rgba(95, 87, 245, 0.15); color: var(--fg); } + +.btn-sm { padding: 0.35rem 0.6rem; font-size: 0.85rem; } + +.btn-icon { + padding: 0.3rem; + background: transparent; + color: var(--muted); +} +.btn-icon:hover:not(:disabled) { color: var(--fg); background: var(--surface-3); transform: none; } + +.btn-block { width: 100%; } + +/* Compact tool buttons in a row (export, replay nav). */ +.btn-tool { + flex: 1; + padding: 0.4rem 0.3rem; + font-size: 0.78rem; + background: var(--surface-2); + border-color: var(--line); + color: var(--fg-soft); +} +.btn-tool:hover:not(:disabled) { border-color: var(--royal); color: var(--fg); transform: none; } +.btn-tool.done { border-color: var(--green); color: var(--green); } + +.btn-nav { + width: 2.1rem; + height: 2.1rem; + padding: 0; + background: var(--surface-2); + border-color: var(--line); + color: var(--fg); +} +.btn-nav:hover:not(:disabled) { border-color: var(--royal); background: var(--surface-3); transform: none; } +.btn-nav .icon { width: 1.2rem; height: 1.2rem; } + +.btn-play[aria-pressed="true"] { background: var(--royal); border-color: var(--royal); } +.btn-play .icon-pause { display: none; } +.btn-play[aria-pressed="true"] .icon-play { display: none; } +.btn-play[aria-pressed="true"] .icon-pause { display: block; } + +#replay-flip[aria-pressed="true"] { border-color: var(--periwinkle); color: var(--periwinkle); } + +#auth-btn[data-status="authenticated"] { color: var(--fg); } + +/* --- Workspace ------------------------------------------------------- */ + +.workspace { flex: 1; min-height: 0; + display: flex; + align-items: center; + justify-content: center; + gap: clamp(1rem, 2vw, 2rem); overflow: hidden; - height: 100%; - max-width: clamp(800px, 51vw, 980px); - margin: 0 auto; - align-items: center; } -.game-area { - display: flex; - align-items: center; - justify-content: center; - min-width: clamp(360px, 22.92vw, 440px); - overflow: visible; - height: 100%; -} - -.board-container { - display: flex; - align-items: center; - justify-content: center; - width: clamp(360px, 22.92vw, 440px); - height: clamp(360px, 22.92vw, 440px); - padding: clamp(16px, 1.04vw, 20px); +.board-area { position: relative; + flex: none; + width: var(--board-edge); + height: var(--board-edge); + padding: var(--board-pad); + display: flex; + align-items: center; + justify-content: center; } .board-wrapper { position: relative; - width: clamp(328px, 20.83vw, 400px); - height: clamp(328px, 20.83vw, 400px); - margin: 0; + width: calc(var(--board-edge) - 2 * var(--board-pad)); + height: calc(var(--board-edge) - 2 * var(--board-pad)); } .coordinates { position: absolute; display: flex; - color: var(--tokyo-border); - font-size: 0.75rem; + color: var(--muted); + font-size: 0.72rem; font-weight: 500; user-select: none; + pointer-events: none; } -.coordinates.top { - top: -18px; +.coordinates.files { + top: -17px; left: 0; right: 0; justify-content: space-around; padding: 0 2px; } -.coordinates.left { - left: -18px; +.coordinates.ranks { + left: -16px; top: 0; bottom: 0; flex-direction: column; @@ -137,375 +296,320 @@ main { #board { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: 0; display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); - border: 3px solid var(--tokyo-border); + border: 3px solid var(--line-strong); border-radius: 4px; + overflow: hidden; } .square { display: flex; justify-content: center; align-items: center; - font-size: clamp(24px, 5vw, 36px); + /* Glyphs scale with the square: 72% of its edge. */ + font-size: calc((var(--board-edge) - 2 * var(--board-pad)) / 8 * 0.72); cursor: pointer; user-select: none; position: relative; - aspect-ratio: 1; overflow: hidden; + line-height: 1; } +body[data-mode="replay"] .square, .square.inert { cursor: default; } + .square.light { background-color: var(--square-light); } .square.dark { background-color: var(--square-dark); } .square.selected { background-color: var(--square-selected) !important; } .square.last-move-from { background-color: var(--move-from) !important; } .square.last-move-to { background-color: var(--move-to) !important; } -.square.white-piece { color: var(--host-white); text-shadow: 1px 1px 2px rgba(0,0,0,0.5); } -.square.black-piece { color: var(--host-bg); text-shadow: 1px 1px 2px rgba(255,255,255,0.2); } +.square.white-piece { color: var(--white); text-shadow: 1px 1px 2px rgba(0,0,0,0.5); } +.square.black-piece { color: var(--bg); text-shadow: 1px 1px 2px rgba(255,255,255,0.2); } -/* Move feedback animations */ -@keyframes flashGreen { - 0%, 100% { background-color: inherit; } - 50% { background-color: rgba(154, 206, 106, 0.8); } -} - -@keyframes flashRed { - 0%, 100% { background-color: inherit; } - 50% { background-color: rgba(247, 118, 142, 0.8); } -} - -.square.flash-green { - animation: flashGreen 0.4s ease-out; -} - -.square.flash-red { - animation: flashRed 0.4s ease-out; - position: relative; - z-index: 2; -} - -.square.flash-red::before { +/* Move feedback: an overlay fades in and out over the square, so the + square's own colour is never touched. */ +.square.flash-green::before, .square.flash-red::before { content: ''; position: absolute; inset: 0; - background-color: rgba(247, 118, 142, 0.8); animation: flashFade 0.4s ease-out; pointer-events: none; } +.square.flash-green::before { background-color: rgba(154, 206, 106, 0.8); } +.square.flash-red::before { background-color: rgba(247, 118, 142, 0.8); } + @keyframes flashFade { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } } -/* Checkmate indicator */ .square.mated-king::after { content: ''; position: absolute; inset: 4px; - border: 3px solid var(--tokyo-red); + border: 3px solid var(--red); border-radius: 2px; pointer-events: none; z-index: 1; } .square.mated-king.white-piece, -.square.mated-king.black-piece { - color: var(--checkmated-king) !important; -} +.square.mated-king.black-piece { color: var(--checkmated-king) !important; } -/* Copy Buttons */ -.copy-btn { +/* Toast: a short message under the board, inside the board area so it never + moves the layout. */ +.toast-host { position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - width: 24px; - height: 24px; - padding: 4px; - background: var(--tokyo-border); - border: none; - border-radius: 4px; - color: var(--tokyo-fg); - cursor: pointer; - opacity: 0; - transition: opacity 0.2s, background 0.2s; + left: 0; + right: 0; + bottom: -4px; display: flex; - align-items: center; justify-content: center; + pointer-events: none; + z-index: 20; } -.move-history-container:hover .copy-btn { - opacity: 0.7; +.toast { + max-width: 92%; + padding: 0.35rem 0.8rem; + border-radius: 999px; + background: var(--surface-3); + border: 1px solid var(--line-strong); + color: var(--fg); + font-size: 0.82rem; + font-weight: 500; + text-align: center; + opacity: 0; + transform: translateY(6px); + transition: opacity 0.2s, transform 0.2s; + box-shadow: 0 6px 20px rgba(0,0,0,0.4); } -.copy-btn:hover { - opacity: 1 !important; - background: var(--host-royal); +.toast.show { opacity: 1; transform: none; } +.toast.error { color: var(--red); border-color: rgba(247, 118, 142, 0.5); } +.toast.info { color: var(--cyan); border-color: rgba(125, 207, 255, 0.4); } +.toast.success { color: var(--green); border-color: rgba(158, 206, 106, 0.5); } + +/* --- Panel ----------------------------------------------------------- */ + +.panel { + flex: none; + width: var(--panel-width); + height: var(--board-edge); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; } -.copy-btn.copied { - background: var(--tokyo-green); - opacity: 1; -} - -.copy-btn.copied svg { - display: none; -} - -.copy-btn.copied::after { - content: '✓'; - font-size: 14px; -} - -/* Info Panel */ -.info-panel { - background: var(--host-bg); - border-radius: 8px; - padding: clamp(0.75rem, 0.83vw, 1rem); +.pane { + height: 100%; + padding: 0.75rem; display: flex; flex-direction: column; - gap: clamp(0.75rem, 0.83vw, 1rem); - overflow: hidden; - width: clamp(280px, 17.71vw, 340px); - height: clamp(360px, 22.92vw, 440px); - align-self: center; + gap: 0.6rem; } -/* Status Indicators */ -.status-indicators { - display: flex; - padding: 0.75rem; - justify-content: space-evenly; - align-items: center; - background: var(--host-gray-muted); - border: none; - border-radius: 6px; - cursor: pointer; - height: auto; - position: relative; -} +body[data-mode="live"] .pane-replay { display: none; } +body[data-mode="replay"] .pane-live { display: none; } -.indicator { - position: relative; +.turn-strip { + flex: none; display: flex; align-items: center; - justify-content: center; - height: 100%; + gap: 0.5rem; + min-height: 1.8rem; + padding: 0.3rem 0.6rem; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font-size: 0.85rem; + color: var(--fg); } -.indicator .light { - font-size: 2rem; - font-weight: 500; - transition: all 0.2s; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; +.turn-dot { + width: 0.7rem; + height: 0.7rem; + border: 1px solid var(--line-strong); } -.indicator .light[data-status="white-wins"] { color: var(--tokyo-red); } -.indicator .light[data-status="black-wins"] { color: var(--tokyo-red); } - -/* Status colors */ -.indicator .light[data-status="healthy"] { color: var(--tokyo-green); } -.indicator .light[data-status="ok"] { color: var(--tokyo-green); } -.indicator .light[data-status="disabled"] { color: var(--tokyo-yellow); } -.indicator .light[data-status="degraded"] { color: var(--tokyo-red); } -.indicator .light[data-status="unknown"] { color: var(--tokyo-border); } -.indicator .light[data-status="white"] { color: var(--host-white); } -.indicator .light[data-status="black"] { color: var(--host-bg); } -.indicator .light[data-status="thinking"] { - color: var(--tokyo-yellow); - animation: pulse 1.5s infinite; -} -.indicator .light[data-status="network-error"] { color: var(--tokyo-red); } - -.indicator .light[data-status="draw"], -.indicator .light[data-status="stalemate"] { - color: var(--tokyo-yellow); -} +.turn-dot[data-status="white"] { background: var(--white); } +.turn-dot[data-status="black"] { background: var(--bg); } +.turn-dot[data-status="thinking"] { background: var(--yellow); animation: pulse 1.5s infinite; } +.turn-dot[data-status="white-wins"], .turn-dot[data-status="black-wins"] { background: var(--red); } +.turn-dot[data-status="draw"], .turn-dot[data-status="stalemate"] { background: var(--yellow); } +.turn-dot[data-status="stuck"] { background: var(--red); } @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(0.95); } + 50% { opacity: 0.5; transform: scale(0.9); } } -.indicator:hover::after { - content: attr(data-tooltip) ": " attr(data-status); - position: absolute; - bottom: -30px; - left: 50%; - transform: translateX(-50%); - background: var(--tokyo-border); - color: var(--tokyo-fg); - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - white-space: nowrap; - z-index: 10; - pointer-events: none; +.turn-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.controls { + flex: none; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; } -/* Error Flash Overlay */ -.error-flash-overlay { - position: absolute; - inset: 0; - background: var(--host-bg); - border-radius: 6px; - display: none; - align-items: center; - justify-content: center; - z-index: 100; - pointer-events: none; -} +.controls .btn { padding: 0.5rem 0.3rem; font-size: 0.82rem; } -.error-flash-overlay.show { - display: flex; - animation: none; -} - -.error-flash-message { - color: var(--tokyo-red); - font-size: 0.85rem; - font-weight: 500; - text-align: center; - padding: 0.5rem; -} - -.error-flash-message.info { - color: var(--tokyo-cyan); -} - -/* Move History */ -.move-history-container { - flex: 1 1 auto; +.moves { + flex: 1; min-height: 0; - max-height: 100%; position: relative; - background: var(--host-surface); - border-radius: 6px; - border: 1px solid var(--tokyo-border); - overflow: hidden; -} - -.move-history-container .copy-btn { - right: 8px; - top: 8px; - transform: none; -} - -.move-history { - height: 100%; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); overflow-y: auto; - overflow-x: auto; - padding: 0.75rem; + overflow-x: hidden; + padding: 0.5rem 0.6rem; + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; } -.move-history::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.move-history::-webkit-scrollbar-track { - background: transparent; -} - -.move-history::-webkit-scrollbar-thumb { - background: var(--tokyo-border); - border-radius: 3px; -} +.moves::-webkit-scrollbar { width: 6px; } +.moves::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 3px; } .move-grid { display: grid; - grid-template-columns: 2.5rem 1fr 1fr; - gap: 0.25rem 0.5rem; - font-family: 'Courier New', monospace; - font-size: 0.9rem; - align-items: center; + grid-template-columns: 2.4rem 1fr 1fr; + gap: 0.15rem 0.3rem; + font-family: var(--mono); + font-size: 0.88rem; + align-items: stretch; +} + +.move-grid:empty { display: none; } +.move-grid:empty + .moves-empty { display: block; } + +.moves-empty { + display: none; + margin: 0; + padding: 0.25rem 0.1rem; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.45; } .move-number { - color: var(--tokyo-yellow); + color: var(--yellow); text-align: right; font-weight: 600; + padding: 0.2rem 0.2rem 0.2rem 0; } -.move-white, -.move-black { - padding: 0.25rem 0.5rem; - border-radius: 3px; +.move, .move-empty { + display: block; + width: 100%; + padding: 0.2rem 0.45rem; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + text-align: left; + font: inherit; + color: var(--fg); } -.move-white:hover, -.move-black:hover { - background: var(--host-royal); +.move-black { color: var(--cyan); } +.move-empty { color: var(--muted); } + +.move-grid-replay .move { cursor: pointer; } +.move-grid-replay .move:hover { background: var(--surface-3); } +.move-grid-replay .move:focus-visible { outline: 2px solid var(--periwinkle); outline-offset: -2px; } +.move-grid-replay .move[aria-current="true"] { background: var(--royal); color: var(--white) !important; border-color: var(--royal); } +/* The live list marks the last move quietly. */ +.move-grid:not(.move-grid-replay) .move[aria-current="true"] { background: var(--surface-3); border-color: var(--line-strong); } + +.toolbar { + flex: none; + display: flex; + gap: 0.35rem; } -.move-white { color: var(--host-gray-light); } -.move-black { color: var(--tokyo-cyan); } +/* --- Replay pane ----------------------------------------------------- */ -/* Controls */ -.controls { - display: grid; - grid-template-columns: 1fr 1fr; +.replay-head { + flex: none; + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.4rem 0.6rem; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font-size: 0.82rem; +} + +.replay-title-row { + display: flex; + align-items: center; gap: 0.5rem; - flex-shrink: 0; } -.btn { - padding: 0.75rem; - border: none; - border-radius: 6px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - color: var(--host-white); - transition: all 0.2s; +.badge { + display: inline-block; + padding: 0.05rem 0.45rem; + border-radius: 999px; + background: var(--royal-soft); + color: var(--white); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; } -.btn-primary { - background: var(--host-blue-primary); - color: var(--host-white); +.replay-result { color: var(--fg); font-weight: 600; } +.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; } + +.replay-players { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.btn-primary:hover { - background: var(--host-royal); - transform: translateY(-1px); +.replay-meta { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.78rem; } -.btn-secondary { - background: var(--host-blue-secondary); - color: var(--host-white); +.replay-nav { + flex: none; + display: flex; + align-items: center; + gap: 0.3rem; } -.btn-secondary:hover:not(:disabled) { - background: var(--host-royal); - transform: translateY(-1px); +.speed { + margin-left: auto; + display: flex; + align-items: center; } -.btn-danger { - background: var(--tokyo-red); - color: var(--host-bg); +.speed select { + height: 2.1rem; + padding: 0 0.4rem; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--fg); + font-size: 0.8rem; } -.btn-danger:hover:not(:disabled) { - filter: brightness(1.1); - transform: translateY(-1px); -} +.speed select:focus-visible { outline: 2px solid var(--periwinkle); } -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} +/* --- Modals ---------------------------------------------------------- */ -/* Modal */ .modal-overlay { display: none; position: fixed; @@ -515,51 +619,53 @@ main { justify-content: center; align-items: center; backdrop-filter: blur(4px); + padding: 1rem; } -.modal-overlay.show { - display: flex; -} +.modal-overlay.show { display: flex; } .modal { - background: var(--host-surface); - border: 1px solid var(--tokyo-border); + background: var(--surface); + border: 1px solid var(--line-strong); border-radius: 12px; - padding: 1.5rem 2rem; /* was 2rem */ - width: 90%; + padding: 1.25rem 1.5rem; + width: 100%; max-width: 400px; - color: var(--tokyo-fg); - /* Last resort only. The spacing below is sized so the tallest modal - (New Game, ~425px) clears the 30rem embed on lixen.com without this ever - engaging — it exists for odd zoom levels and very short windows. The - overlay is fixed to the IFRAME viewport, so without it the modal clips - instead of scrolling the page. */ - max-height: calc(100% - 2rem); + color: var(--fg-soft); + /* The overlay is fixed to the IFRAME viewport, so a modal taller than the + frame scrolls inside itself instead of clipping. */ + max-height: 100%; overflow-y: auto; overscroll-behavior: contain; + box-shadow: 0 20px 50px rgba(0,0,0,0.5); } .modal h2 { - margin-bottom: 1rem; - color: var(--host-royal); + margin: 0 0 0.75rem; + color: var(--periwinkle); text-align: center; + font-size: 1.2rem; } -.form-group { - margin-bottom: 1rem; -} - -.form-group label { - display: block; +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; margin-bottom: 0.5rem; - color: var(--tokyo-cyan); } -.form-group .group-label { - color: var(--tokyo-cyan); +.modal-head h2 { margin: 0; text-align: left; } + +.form-group { margin-bottom: 0.9rem; } + +.form-group label, .form-group .group-label { + display: block; + margin-bottom: 0.4rem; + color: var(--cyan); + font-size: 0.9rem; } -/* Label and control on one line: the colour choice. */ .form-group.form-row { display: flex; align-items: center; @@ -567,388 +673,149 @@ main { gap: 1rem; } +.form-group.form-row .group-label { margin: 0; } + .radio-group { display: flex; - gap: 1.5rem; - justify-content: center; + gap: 1.25rem; } .radio-group label { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.4rem; margin: 0; - color: var(--tokyo-fg); + color: var(--fg-soft); + font-size: 0.9rem; } input[type="range"] { width: 100%; height: 4px; - background: var(--tokyo-border); + background: var(--line-strong); border-radius: 2px; outline: none; + appearance: none; } input[type="range"]::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; - background: var(--host-gray-muted); + background: var(--periwinkle); border-radius: 50%; cursor: pointer; } -/* Modal FEN input */ -.fen-input { - width: 100%; - padding: 0.5rem; - background: var(--host-bg); - border: 1px solid var(--tokyo-border); - border-radius: 4px; - color: var(--tokyo-fg); - font-family: 'Courier New', monospace; - font-size: 0.85rem; - resize: none; - white-space: pre-wrap; - word-wrap: break-word; -} - -.fen-input:focus { - outline: none; - border-color: var(--host-royal); -} - -.modal-buttons { - display: flex; - justify-content: center; - gap: 1rem; - margin-top: 1.5rem; -} - -/* Auth Tabs */ -.auth-modal { - max-width: 360px; -} - -.auth-tabs { - display: flex; - gap: 0; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--tokyo-border); -} - -.auth-tab { - flex: 1; - padding: 0.75rem; - background: transparent; +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--periwinkle); border: none; - color: var(--tokyo-fg); + border-radius: 50%; cursor: pointer; - font-size: 0.95rem; - transition: color 0.2s, border-color 0.2s; - border-bottom: 2px solid transparent; - margin-bottom: -1px; } -.auth-tab:hover { - color: var(--host-royal); -} - -.auth-tab.active { - color: var(--host-royal); - border-bottom-color: var(--host-royal); -} +input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outline-offset: 4px; } +.fen-input, .auth-form input[type="text"], .auth-form input[type="email"], .auth-form input[type="password"], .account-form input[type="password"] { width: 100%; - padding: 0.5rem; - background: var(--host-bg); - border: 1px solid var(--tokyo-border); - border-radius: 4px; - color: var(--tokyo-fg); + padding: 0.5rem 0.6rem; + background: var(--bg); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--fg); font-size: 0.9rem; } -.auth-form input:focus, -.account-form input:focus { +.fen-input { + font-family: var(--mono); + font-size: 0.82rem; + resize: none; + white-space: pre-wrap; + word-wrap: break-word; +} + +.fen-input:focus, .auth-form input:focus, .account-form input:focus { outline: none; - border-color: var(--host-royal); + border-color: var(--royal); } -/* Auth Indicator */ -.auth-indicator { - cursor: pointer; - border: 1px solid transparent; - border-radius: 6px; - transition: border-color .2s, background .2s; +.modal-buttons { + display: flex; + justify-content: center; + gap: 0.75rem; + margin-top: 1.1rem; } -.auth-indicator:hover { - border-color: var(--host-royal); - background: rgba(95, 87, 245, 0.15); +.auth-modal, .account-modal { max-width: 360px; } + +.auth-tabs { + display: flex; + margin-bottom: 0.9rem; + border-bottom: 1px solid var(--line-strong); } -.auth-indicator .light[data-status="anonymous"] { - color: var(--tokyo-yellow); +.auth-tab { + flex: 1; + padding: 0.6rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + color: var(--fg-soft); + font-size: 0.95rem; + transition: color 0.2s, border-color 0.2s; } -.auth-indicator .light[data-status="authenticated"] { - color: var(--tokyo-green); -} +.auth-tab:hover { color: var(--periwinkle); } +.auth-tab.active { color: var(--periwinkle); border-bottom-color: var(--periwinkle); } -/* --- Modal status message --- - A reserved slot, not a block that appears: an empty message keeps its height - and is only invisible, so a message never moves the form or pushes the - buttons out of a short frame (the embedded board on lixen.com is one). Sized - for one line; a longer message wraps and grows the slot, which the modal's - own scroll absorbs. */ +/* A reserved slot: an empty message keeps its height and is only invisible, + so a message never moves the form or pushes the buttons out of a short + frame. */ .modal-message { display: flex; align-items: center; justify-content: center; min-height: 2.3rem; - margin-bottom: 0.75rem; + margin-bottom: 0.6rem; padding: 0.35rem 0.75rem; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--radius-sm); font-size: 0.85rem; line-height: 1.3; text-align: center; visibility: hidden; } -.modal-message.show { - visibility: visible; +.modal-message.show { visibility: visible; } +.modal-message.error { background: rgba(247, 118, 142, 0.12); color: var(--red); border-color: rgba(247, 118, 142, 0.4); } +.modal-message.success { background: rgba(158, 206, 106, 0.12); color: var(--green); border-color: rgba(158, 206, 106, 0.4); } + +.auth-form input:disabled, .account-form input:disabled, .auth-tab:disabled, +.fen-input:disabled, input[type="range"]:disabled { opacity: 0.5; cursor: not-allowed; } +.radio-group input:disabled + span { opacity: 0.5; } + +.account-name { text-align: center; margin: 0.25rem 0; } + +.danger-zone { + margin-top: 1.25rem; + padding-top: 0.9rem; + border-top: 1px solid var(--line-strong); } -.modal-message.error { - background: rgba(247, 118, 142, 0.12); - color: var(--tokyo-red); - border: 1px solid rgba(247, 118, 142, 0.4); -} +.danger-zone summary { cursor: pointer; color: var(--red); text-align: center; } +.danger-zone p { margin: 0.75rem 0 0.9rem; font-size: 0.85rem; line-height: 1.4; } +.danger-zone .modal-buttons { margin-top: 0.9rem; } -.modal-message.success { - background: rgba(158, 206, 106, 0.12); - color: var(--tokyo-green); - border: 1px solid rgba(158, 206, 106, 0.4); -} +.confirm-modal { max-width: 320px; } -/* --- Disabled state while a modal request is in flight (Issue 1) --- */ -.auth-form input:disabled, -.account-form input:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.auth-tab:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.fen-input:disabled, -input[type="range"]:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.radio-group input:disabled + span { - opacity: 0.5; -} - -/* Mobile/Responsiveness */ -@media (max-width: 978px) { - - body { - height: auto; - min-height: 100vh; - overflow-y: auto; - overflow-x: hidden; - scrollbar-gutter: stable; - } - - .outer-container { - padding: 8px; - min-height: 100vh; - height: auto; - display: flex; - justify-content: center; - align-items: flex-start; - overflow: visible; - } - - .container { - border-radius: 12px; - width: calc(100% - 16px); - min-height: calc(100vh - 16px); - height: auto; - padding: 1rem; - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - overflow: visible; - } - - main { - grid-template-columns: 1fr; - grid-template-rows: auto auto; - gap: 0.5rem; - height: auto; - max-width: none; - margin: 0 auto; - align-items: center; - justify-items: center; - overflow: visible; - flex-shrink: 0; - } - - /* Board edge = the smaller of the width-derived size and the height that is - actually left. Reserve = info-panel 200 + main gap 8 + .container padding - 32 + .outer-container padding 16 = 256, plus 16 slack. A short frame now - shrinks the board instead of scrolling; overflow-y stays as last resort. */ - .game-area { - --board-pad: clamp(16px, 2.04vw, 20px); - --board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 272px))); - min-width: auto; - height: auto; - display: flex; - justify-content: center; - align-items: center; - } - - .board-container { - width: var(--board-edge); - height: var(--board-edge); - padding: var(--board-pad); - display: flex; - align-items: center; - justify-content: center; - position: relative; - flex-shrink: 0; - } - - .board-wrapper { - width: calc(var(--board-edge) - 2 * var(--board-pad)); - height: calc(var(--board-edge) - 2 * var(--board-pad)); - position: relative; - margin: 0; - } - - .coordinates.top { - top: -18px; - left: 0; - right: 0; - } - - .coordinates.left { - left: -18px; - top: 0; - bottom: 0; - } - - #board { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - - .info-panel { - width: clamp(360px, 45vw, 440px); - height: clamp(180px, 20.4vw, 200px); - display: grid; - grid-template-columns: clamp(150px, 18.4vw, 180px) 1fr; - grid-template-rows: min-content min-content 1fr; - gap: 0.5rem 1rem; - padding: clamp(0.75rem, 1.02vw, 1rem); - align-self: center; - overflow: hidden; - flex-shrink: 0; - margin-bottom: 1rem; - } - - .status-indicators { - grid-column: 1; - grid-row: 1; - display: flex; - padding: 0.25rem; - justify-content: space-evenly; - align-items: center; - background: var(--host-gray-muted); - border: none; - border-radius: 6px; - height: auto; - margin: 0; - } - - /* Four controls as a 2x2 grid; the column is too short for one per row. */ - .controls { - grid-column: 1; - grid-row: 2 / 4; - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr 1fr; - gap: 0.4rem; - align-self: stretch; - height: auto; - min-height: 80px; - } - - .btn { - padding: 0.4rem 0.25rem; - font-size: 0.8rem; - min-height: 35px; - height: 100%; - width: 100%; - } - - .move-history-container { - grid-column: 2; - grid-row: 1 / 4; - height: 100%; - min-height: 0; - max-height: 100%; - align-self: stretch; - } - - .move-grid { - grid-template-columns: 2rem 1fr 1fr; - gap: 0.25rem 0.5rem; - font-size: 0.85rem; - } - - .move-history { - padding: 0.5rem; - } - - .square { - font-size: clamp(24px, 5vw, 36px); - } -} - -@media (max-width: 530px) { - body { min-width: 0; overflow-x: hidden; } - .outer-container { width: 100%; min-width: 0; } - .container { width: calc(100% - 16px); min-width: 0; } - - .game-area { - --board-pad: 12px; - --board-edge: max(240px, min(min(92vw, 440px), calc(100vh - 272px))); - } - - .info-panel { - width: min(92vw, 440px); - min-width: 0; - } -} - -/* Promotion picker */ -.promotion-modal { - max-width: 320px; -} +.promotion-modal { max-width: 320px; } .promotion-choices { display: flex; @@ -965,65 +832,285 @@ input[type="range"]:disabled { font-size: 36px; line-height: 1; background: var(--square-light); - border: 2px solid var(--tokyo-border); + border: 2px solid var(--line-strong); border-radius: 6px; - cursor: pointer; transition: border-color 0.2s, background 0.2s; } -.promotion-choice:hover, -.promotion-choice:focus-visible { +.promotion-choice:hover, .promotion-choice:focus-visible { outline: none; - border-color: var(--host-royal); + border-color: var(--royal); background: var(--square-selected); } -.promotion-choices.white .promotion-choice { - color: var(--host-white); - text-shadow: 1px 1px 2px rgba(0,0,0,0.5); +.promotion-choices.white .promotion-choice { color: var(--white); text-shadow: 1px 1px 2px rgba(0,0,0,0.5); } +.promotion-choices.black .promotion-choice { color: var(--bg); text-shadow: 1px 1px 2px rgba(255,255,255,0.2); } + +/* --- My games drawer ------------------------------------------------- */ + +.games-modal { + max-width: 580px; + height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + padding: 1rem 1.25rem; } -.promotion-choices.black .promotion-choice { - color: var(--host-bg); - text-shadow: 1px 1px 2px rgba(255,255,255,0.2); +.games-filters { + flex: none; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.6rem; } -/* Account */ -.account-modal { - max-width: 360px; +.segmented { + display: inline-flex; + border: 1px solid var(--line-strong); + border-radius: 999px; + overflow: hidden; } -.account-name { - text-align: center; +.seg { + padding: 0.25rem 0.7rem; + background: transparent; + border: none; + color: var(--muted); + font-size: 0.8rem; } -.danger-zone { - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--tokyo-border); +.seg:hover { color: var(--fg); } +.seg.active { background: var(--royal-soft); color: var(--white); } +.seg:focus-visible { outline: 2px solid var(--periwinkle); outline-offset: -2px; } + +.games-body { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; + padding-right: 0.25rem; } -.danger-zone summary { - cursor: pointer; - color: var(--tokyo-red); - text-align: center; +.games-section { margin-bottom: 1rem; } +.games-section.hidden { display: none; } + +.games-heading { + margin: 0 0 0.4rem; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); } -.danger-zone p { - margin: 0.75rem 0 1rem; +.games-note { + margin: 0 0 0.6rem; font-size: 0.85rem; - line-height: 1.4; + color: var(--muted); } -.danger-zone .modal-buttons { - margin-top: 1rem; +.games-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; } -/* Resign confirmation */ -.confirm-modal { - max-width: 320px; +.game-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.45rem 0.6rem; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--fg-soft); + text-align: left; + transition: border-color 0.15s, background 0.15s; } -.confirm-modal h2 { - font-size: 1.2rem; +.game-row:hover, .game-row:focus-visible { + outline: none; + border-color: var(--royal); + background: var(--surface-3); +} + +.game-row.current { border-color: var(--periwinkle); } + +.mini-board { + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); + width: 72px; + height: 72px; + border: 1px solid var(--line-strong); + border-radius: 3px; + overflow: hidden; + flex: none; +} + +.mini-board span { + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + line-height: 1; + user-select: none; +} + +.mini-board .light { background: var(--square-light); } +.mini-board .dark { background: var(--square-dark); } +.mini-board .white-piece { color: var(--white); text-shadow: 0 0 1px rgba(0,0,0,0.6); } +.mini-board .black-piece { color: var(--bg); } + +.game-info { min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; } + +.game-players { + color: var(--fg); + font-weight: 500; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-outcome { font-size: 0.82rem; } +.game-outcome .pgn-result { font-family: var(--mono); color: var(--yellow); margin-right: 0.35rem; } +.game-outcome.ongoing .pgn-result { color: var(--green); } + +.game-when { font-size: 0.78rem; color: var(--muted); } + +.game-go { + color: var(--periwinkle); + display: flex; + align-items: center; +} + +.games-status { + margin: 0.4rem 0; + font-size: 0.85rem; + color: var(--muted); + text-align: center; +} + +.games-status.error { color: var(--red); } +.games-status:empty { display: none; } + +#games-more { margin-top: 0.4rem; } +#games-more.hidden { display: none; } + +/* --- Stacked layout (board over panel) -------------------------------- */ + +@media (max-width: 978px) { + :root { + --panel-width: clamp(360px, 45vw, 440px); + --panel-height: 230px; + /* Board edge = the smaller of the width-derived size and the height + that is left: topbar 40 + gaps 16 + panel 230 + app padding 16 + + slack 40 = 342. A short frame shrinks the board before anything + scrolls. */ + --board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 342px))); + --board-pad: clamp(16px, 2.04vw, 20px); + } + + body { overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; } + + .app { height: auto; min-height: 100%; } + + .workspace { + flex-direction: column; + gap: 0.5rem; + overflow: visible; + } + + .panel { + width: var(--panel-width); + height: var(--panel-height); + } + + .pane { + display: grid; + grid-template-columns: clamp(150px, 18.4vw, 180px) 1fr; + grid-template-rows: min-content 1fr; + gap: 0.5rem 0.75rem; + padding: 0.6rem; + } + + body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; } + + .pane-live .turn-strip { grid-column: 1; grid-row: 1; } + .pane-live .controls { + grid-column: 1; + grid-row: 2; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + align-self: stretch; + } + .pane-live .controls .btn { padding: 0.3rem 0.2rem; font-size: 0.78rem; min-height: 2rem; } + .pane-live .moves { grid-column: 2; grid-row: 1 / 3; } + .pane-live .toolbar { grid-column: 2; grid-row: 3; } + .pane-live { grid-template-rows: min-content 1fr min-content; } + .pane-live .controls { grid-row: 2 / 4; } + + /* Replay: head, navigation and the exit button down the left; the move + list with the export row on the right. */ + .pane-replay { grid-template-rows: min-content min-content 1fr min-content; } + .pane-replay .replay-head { grid-column: 1; grid-row: 1; padding: 0.3rem 0.5rem; } + .pane-replay .replay-nav { + grid-column: 1; + grid-row: 2; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.25rem; + } + .pane-replay .replay-nav .speed { grid-column: 1 / 5; margin: 0; } + .pane-replay .replay-nav .speed select { width: 100%; } + .pane-replay #replay-exit { grid-column: 1; grid-row: 3 / 5; align-self: end; padding: 0.35rem; font-size: 0.78rem; } + .pane-replay .moves { grid-column: 2; grid-row: 1 / 4; } + .pane-replay .toolbar { grid-column: 2; grid-row: 4; } + .pane-replay .btn-nav { width: 100%; height: 1.65rem; } + .pane-replay .btn-nav .icon { width: 1rem; height: 1rem; } + .pane-replay .speed select { height: 1.65rem; font-size: 0.72rem; padding: 0 0.2rem; } + .pane-replay .replay-meta { display: none; } + .pane-replay .replay-title-row { flex-wrap: wrap; gap: 0.2rem 0.4rem; } + .pane-replay .replay-players { font-size: 0.76rem; } + .pane-replay .replay-result { font-size: 0.76rem; } + + .move-grid { grid-template-columns: 2rem 1fr 1fr; font-size: 0.84rem; } + .btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; } +} + +@media (max-width: 530px) { + :root { + --panel-width: min(92vw, 440px); + --board-edge: max(240px, min(min(92vw, 440px), calc(100vh - 332px))); + --board-pad: 12px; + } + + .brand-name, #games-btn span, #auth-label { display: none; } + /* Icon-only export row in the replay pane: four buttons in 190px. */ + .pane-replay .toolbar .btn-tool span { display: none; } + .pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; } + #games-btn, #auth-btn { padding: 0.35rem 0.5rem; } + .pane { grid-template-columns: 140px 1fr; } + .games-modal { padding: 0.75rem; } + .game-row { gap: 0.5rem; padding: 0.4rem; } + .mini-board { width: 56px; height: 56px; } + .mini-board span { font-size: 6px; } + .modal-overlay { padding: 0.5rem; } +} + +/* --- Motion ---------------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + } } diff --git a/test/README.md b/test/README.md index 0f40962..4833edc 100755 --- a/test/README.md +++ b/test/README.md @@ -38,9 +38,11 @@ The server runs with `-dev`, debug logging, HTTP request logging, and relaxed ra It drops the test tables on exit, so it's preferred for clean testing. Can be used for all the tests. -Logging can be adjusted without editing the script: +Logging can be adjusted without editing the script, and `WEB_PORT` also +serves the embedded web client (needed by the end-to-end suite): ```bash LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server +WEB_PORT=9090 test/run-test-server.sh bin/chess-server ``` ### Pre-configured Users @@ -83,6 +85,7 @@ curl -X POST http://localhost:8080/api/games \ | End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions | | Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing | | Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, concessions and claim control, server version, hand-deleted game row | +| Web client (end-to-end) | `e2e/` | Playwright in Chromium against the embedded web client: play, My games, replay controls and keyboard, autoplay, FEN/PGN export and download compared with the API, deep links, device list | | Test Server | `run-test-server.sh` | Pre-populated test environment | ## 1. API Functionality Tests (`test-api.sh`) @@ -216,3 +219,38 @@ unknown route answered with `NOT_FOUND`, `/health` reporting the build, and a game whose row is deleted with psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and stays healthy). + +## Web client end-to-end tests (`e2e/`) + +Playwright drives the embedded web client in Chromium against a running +server with PostgreSQL and Stockfish: it plays a short game, lists it under +My games, replays it with the buttons and the keyboard, autoplays to the end, +compares the FEN and PGN it exports (copy and download) with the API, follows +a deep link to a checkmate, steps back through the browser history, and +replays an anonymous game from the device list. + +Requirements: Node.js 20+ and a Chromium that Playwright can run. + +```bash +# Terminal 1: the test server with the web client +WEB_PORT=9090 test/run-test-server.sh bin/chess-server + +# Terminal 2, once +cd test/e2e && npm install && npx playwright install chromium + +# Terminal 2: run +npm test # or, from the repository root: make test-e2e +``` + +Environment: + +- `CHESS_E2E_WEB` (default `http://localhost:9090`): the web client's origin +- `CHESS_E2E_API` (default `http://localhost:8080`): the API's origin, used + to register users, create games and fetch what the client should export +- `CHESS_E2E_CHROMIUM`: path to a Chromium binary when + `npx playwright install` is not possible (an already installed browser) + +Each test registers its own user or plays anonymously, so the suite can be +repeated against the same server. The engine plays at level 0 with a 100 ms +search, so a run takes about ten seconds. On failure Playwright keeps a trace +under `test/e2e/test-results/`; open it with `npx playwright show-trace`. diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore new file mode 100644 index 0000000..babfa03 --- /dev/null +++ b/test/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules +test-results +playwright-report diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 0000000..e81f222 --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "chess-web-e2e", + "private": true, + "description": "Playwright end-to-end tests for the chess web client (see test/README.md)", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.57.0" + } +} diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js new file mode 100644 index 0000000..f0a940a --- /dev/null +++ b/test/e2e/playwright.config.js @@ -0,0 +1,29 @@ +// Playwright configuration for the web client's end-to-end tests. The server +// must already run with -serve and PostgreSQL (test/run-test-server.sh with +// WEB_PORT, or any -dev -serve server): CHESS_E2E_WEB names the client's +// origin and CHESS_E2E_API the API's. CHESS_E2E_CHROMIUM points at an +// installed Chromium binary when `npx playwright install chromium` is not an +// option. +'use strict'; +const { defineConfig } = require('@playwright/test'); + +const chromium = process.env.CHESS_E2E_CHROMIUM; + +module.exports = defineConfig({ + testDir: '.', + testMatch: '*.spec.js', + timeout: 120_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: process.env.CHESS_E2E_WEB || 'http://localhost:9090', + viewport: { width: 980, height: 480 }, + permissions: ['clipboard-read', 'clipboard-write'], + acceptDownloads: true, + trace: 'retain-on-failure', + launchOptions: chromium ? { executablePath: chromium } : {}, + }, +}); diff --git a/test/e2e/replay.spec.js b/test/e2e/replay.spec.js new file mode 100644 index 0000000..9e0097c --- /dev/null +++ b/test/e2e/replay.spec.js @@ -0,0 +1,198 @@ +// End-to-end: play a short game on the board, open My games, replay it with +// the controls and the keyboard, autoplay to the end, and compare the +// exported FEN and PGN with the API. Also the deep link, a checkmate's +// replay, and the device list for an anonymous visitor. +'use strict'; +const { test, expect } = require('@playwright/test'); + +const API = process.env.CHESS_E2E_API || 'http://localhost:8080'; +const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +// The engine level and search time are set through the sliders' inputs. +async function startGame(page, { color = 'white' } = {}) { + await page.click('#new-game-btn'); + await expect(page.locator('#modal-overlay')).toHaveClass(/show/); + await page.locator('#computer-level').fill('0'); + await page.locator('#search-time').fill('100'); + await page.check(`input[name="player-color"][value="${color}"]`); + await page.click('#start-game-btn'); + await expect(page.locator('#modal-overlay')).not.toHaveClass(/show/); +} + +async function move(page, from, to) { + await page.click(`[data-square="${from}"]`); + await page.click(`[data-square="${to}"]`); +} + +// Waits for the computer's reply: n plies in the list and the board free. +async function expectPlies(page, n) { + await expect(page.locator('#live-moves .move')).toHaveCount(n, { timeout: 60_000 }); + await expect(page.locator('#turn-text')).not.toContainText('thinking', { timeout: 60_000 }); +} + +async function register(page, request) { + const username = `e2e${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`; + const response = await request.post(`${API}/api/auth/register`, { + data: { username, password: 'Passw0rd123' }, + }); + expect(response.status(), await response.text()).toBe(201); + const auth = await response.json(); + // The client restores a session from localStorage on load. + await page.addInitScript(token => localStorage.setItem('authToken', token), auth.token); + return { ...auth, password: 'Passw0rd123' }; +} + +test.describe('web replay', () => { + test('play, list, replay, export', async ({ page, request }) => { + const user = await register(page, request); + await page.goto('/'); + await expect(page.locator('#auth-label')).toHaveText(user.username); + + await startGame(page); + await move(page, 'e2', 'e4'); + await expectPlies(page, 2); + await move(page, 'g1', 'f3'); + await expectPlies(page, 4); + // The live list shows SAN from the server. + await expect(page.locator('#live-moves .move').first()).toHaveText('e4'); + await expect(page.locator('#live-moves .move').nth(2)).toHaveText('Nf3'); + const gameId = await page.evaluate(() => live.gameId); + const liveFen = await page.evaluate(() => live.fen); + const history = await (await request.get(`${API}/api/games/${gameId}/history`)).json(); + expect(history.moves.map(m => m.san)).toEqual(await page.$$eval('#live-moves .move', els => els.map(e => e.textContent))); + + // Copy PGN from the live view: the stored game's export. + await page.click('#copy-pgn-btn'); + await expect(page.locator('#copy-pgn-btn')).toHaveClass(/done/); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + const apiPgn = await (await request.get(`${API}/api/games/${gameId}/pgn`)).text(); + expect(copied).toBe(apiPgn); + + // My games lists the game with the user's side named. + await page.click('#games-btn'); + await expect(page.locator('#games-overlay')).toHaveClass(/show/); + const row = page.locator('#games-list .game-row').first(); + await expect(row).toContainText('You vs Stockfish L0'); + await expect(row).toContainText('Ongoing'); + await expect(row).toHaveClass(/current/); + await expect(page).toHaveURL(/\?games$/); + await page.click('.seg[data-filter="status"][data-value="finished"]'); + await expect(page.locator('#games-status')).toContainText('No games match'); + await page.click('.seg[data-filter="status"][data-value=""]'); + await expect(page.locator('#games-list .game-row')).toHaveCount(1); + + // Replay from the list opens at the last ply; the live controls hide. + await row.click(); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay'); + await expect(page.locator('#replay-ply')).toHaveText('4 / 4'); + await expect(page).toHaveURL(new RegExp(`\\?replay=${gameId}&ply=4$`)); + await expect(page.locator('#pane-live')).toBeHidden(); + await expect(page.locator('#replay-moves .move[aria-current="true"]')).toHaveText(history.moves[3].san); + + // Backward and forward: buttons, then the keyboard. + await page.click('#replay-first'); + await expect(page.locator('#replay-ply')).toHaveText('0 / 4'); + await expect(page.locator('#replay-first')).toBeDisabled(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('#replay-ply')).toHaveText('2 / 4'); + await expect(page.locator('#ply-announcer')).toContainText('Ply 2 of 4'); + await expect(page).toHaveURL(new RegExp(`ply=2$`)); + await page.keyboard.press('ArrowLeft'); + await expect(page.locator('#replay-ply')).toHaveText('1 / 4'); + await page.keyboard.press('End'); + await expect(page.locator('#replay-ply')).toHaveText('4 / 4'); + await page.keyboard.press('Home'); + await expect(page.locator('#replay-ply')).toHaveText('0 / 4'); + await page.click('#replay-moves .move[data-ply="3"]'); + await expect(page.locator('#replay-ply')).toHaveText('3 / 4'); + + // The exported FEN and PGN at ply 3 match the API. + await page.click('#replay-copy-fen'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(history.moves[2].fenAfterMove); + await page.click('#replay-copy-pgn'); + await expect(page.locator('#replay-copy-pgn')).toHaveClass(/done/); + const pgn3 = await (await request.get(`${API}/api/games/${gameId}/pgn?ply=3`)).text(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(pgn3); + const downloadPromise = page.waitForEvent('download'); + await page.click('#replay-download'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^chess-\d{8}-[0-9a-f]{8}-ply3\.pgn$/); + await page.click('#replay-link'); + expect(await page.evaluate(() => navigator.clipboard.readText())) + .toBe(`${new URL(page.url()).origin}/?replay=${gameId}&ply=3`); + + // Autoplay to the end at 4x, then pause state clears. + await page.selectOption('#replay-speed', '250'); + await page.click('#replay-play'); + await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#replay-ply')).toHaveText('4 / 4', { timeout: 10_000 }); + await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'false'); + + // Flip, then back to the live game, which continues where it was. + await page.click('#replay-flip'); + await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1'); + await page.click('#replay-exit'); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'live'); + await expect(page.locator('.coordinates.ranks span').first()).toHaveText('8'); + expect(await page.evaluate(() => live.fen)).toBe(liveFen); + await expect(page.locator('#live-moves .move')).toHaveCount(4); + await move(page, 'b1', 'c3'); + await expectPlies(page, 6); + + // Browser history: back to the replay, back again to the list. + await page.goBack(); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay'); + await page.goBack(); + await expect(page.locator('#games-overlay')).toHaveClass(/show/); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'live'); + }); + + test('deep link to a checkmate replays with the mated king marked', async ({ page, request }) => { + // A human-vs-human game over the API: fool's mate. + const created = await request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } }); + expect(created.status()).toBe(201); + const { gameId } = await created.json(); + for (const m of ['f2f3', 'e7e5', 'g2g4', 'd8h4']) { + const r = await request.post(`${API}/api/games/${gameId}/moves`, { data: { move: m } }); + expect(r.status(), await r.text()).toBe(200); + } + await page.goto(`/?replay=${gameId}&ply=2`); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay'); + await expect(page.locator('#replay-ply')).toHaveText('2 / 4'); + await expect(page.locator('#replay-result')).toContainText('0-1'); + await expect(page.locator('#replay-result')).toContainText('Checkmate'); + await expect(page.locator('#replay-exit')).toHaveText('Close replay'); + await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#'); + await page.keyboard.press('End'); + await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/); + await expect(page.locator('[data-square="h4"]')).toHaveClass(/last-move-to/); + // Escape leaves the replay; with no live game the start position shows. + await page.keyboard.press('Escape'); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'live'); + await expect(page.locator('#turn-text')).toHaveText('No game'); + await expect(page).toHaveURL(/\/$/); + }); + + test('an anonymous visitor replays from the device list', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#auth-label')).toHaveText('Sign in'); + await startGame(page, { color: 'black' }); + await expectPlies(page, 1); + // Black at the bottom while playing Black. + await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1'); + await page.click('#games-btn'); + await expect(page.locator('#games-signin-note')).toBeVisible(); + const row = page.locator('#local-games-list .game-row'); + await expect(row).toHaveCount(1); + await expect(row).toContainText('Stockfish L0 vs You'); + await row.click(); + await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay'); + await expect(page.locator('#replay-ply')).toHaveText('1 / 1'); + // The viewer's side stays at the bottom in the replay. + await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1'); + expect(await page.evaluate(() => replay.history.initialFen)).toBe(START_FEN); + await page.click('#replay-exit'); + await expect(page.locator('#turn-text')).toContainText('Your move (Black)'); + }); +}); diff --git a/test/run-test-server.sh b/test/run-test-server.sh index 42318f3..d82d8e2 100755 --- a/test/run-test-server.sh +++ b/test/run-test-server.sh @@ -10,6 +10,8 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"} export CHESS_DSN="$CHESS_TEST_DSN" PID_FILE="/tmp/chess-server_test.pid" API_PORT=${API_PORT:-8080} +# Set WEB_PORT to also serve the embedded web client (for test/e2e). +WEB_PORT=${WEB_PORT:-} LOG_LEVEL=${LOG_LEVEL:-debug} LOG_HTTP=${LOG_HTTP:-true} @@ -95,7 +97,7 @@ echo "" echo "Configuration:" echo " Executable: $CHESS_SERVER_EXEC" echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)" -echo " Port: $API_PORT" +echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}" echo " Mode: Development (relaxed rate limits; PostgreSQL storage)" echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)" echo " Purpose: Backend for chess-server tests" @@ -111,10 +113,13 @@ echo "Starting server..." echo "" # Start chess-server in foreground with dev mode and storage +WEB_FLAGS=() +[ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT") "$CHESS_SERVER_EXEC" \ -dev \ -log-level "$LOG_LEVEL" \ -log-http="$LOG_HTTP" \ -api-port "$API_PORT" \ + "${WEB_FLAGS[@]}" \ -pid "$PID_FILE" \ -pid-lock