v0.14.0 web replay UI, My games, CLI replay
This commit is contained in:
1 parent
87dcb3e550
commit
35ed475904
35 files changed
+4049
-1800
No files matched your search
@@ -43,3 +43,55 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
GOOS=freebsd GOARCH=amd64 go build -trimpath -o /dev/null ./cmd/chess-server
|
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
|
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
|
||||||
@@ -167,6 +167,12 @@ test-replay:
|
|||||||
test-draw:
|
test-draw:
|
||||||
test/test-draw.sh
|
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
|
# Database operations
|
||||||
.PHONY: db-init
|
.PHONY: db-init
|
||||||
db-init: server
|
db-init: server
|
||||||
@@ -251,6 +257,7 @@ help:
|
|||||||
@echo " make test-endstate Run end-state tests"
|
@echo " make test-endstate Run end-state tests"
|
||||||
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
|
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
|
||||||
@echo " make test-draw Run draw and resignation 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 ""
|
||||||
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
||||||
@echo " make db-init Create or migrate the schema"
|
@echo " make db-init Create or migrate the schema"
|
||||||
|
|||||||
@@ -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
|
- Visual chess board with click-to-move and a promotion picker
|
||||||
- Human vs Computer gameplay, with undo, draw offers and resignation
|
- Human vs Computer gameplay, with undo, draw offers and resignation
|
||||||
- Configurable engine strength (0-20)
|
- Configurable engine strength (0-20) and custom starting positions
|
||||||
- UCI move history with move numbers
|
- Move list in standard algebraic notation; copy the FEN or the PGN
|
||||||
- FEN display and custom starting positions
|
- **My games**: a signed-in user's stored games with thumbnails, filters and
|
||||||
- Real-time server health monitoring
|
paging; games played anonymously on the device are listed for 24 hours
|
||||||
- User authentication support
|
- **Replay** of any stored game: step, autoplay, flip, keyboard navigation,
|
||||||
- Responsive design for mobile devices
|
export FEN and PGN at any ply, and shareable links
|
||||||
|
(`?replay=<gameId>&ply=<n>`)
|
||||||
|
- 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.
|
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
|
- [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh
|
||||||
- [Architecture](./doc/architecture.md) - System design with auth layer
|
- [Architecture](./doc/architecture.md) - System design with auth layer
|
||||||
- [Development](./doc/development.md) - Build, test, and user management
|
- [Development](./doc/development.md) - Build, test, and user management
|
||||||
- [Client Guide](./doc/client.md) - Interactive debugging client
|
- [Client Guide](./doc/client.md) - Interactive debugging client, including replay
|
||||||
- [Replay Plan](./doc/todo.md) - Phased game-replay implementation and open decisions
|
- [Replay Plan](./doc/todo.md) - Game-replay phases, decisions, and what is left
|
||||||
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -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
|
// Add game state if available
|
||||||
if s.CurrentGameState != nil {
|
if s.CurrentGameState != nil {
|
||||||
turnInfo := " - Turn:"
|
turnInfo := " - Turn:"
|
||||||
|
|||||||
+8
-2
@@ -180,6 +180,7 @@ Authorization: Bearer <token>
|
|||||||
"turn": "w",
|
"turn": "w",
|
||||||
"state": "ongoing",
|
"state": "ongoing",
|
||||||
"moves": [],
|
"moves": [],
|
||||||
|
"san": [],
|
||||||
"players": {
|
"players": {
|
||||||
"white": {"id": "550e8400-...", "color": 1, "type": 1},
|
"white": {"id": "550e8400-...", "color": 1, "type": 1},
|
||||||
"black": {"id": "ai-player-...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
"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.
|
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:**
|
**Timeout behavior:**
|
||||||
- Returns current state after 30 seconds even if no changes
|
- 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.
|
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,
|
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,
|
or the initial FEN when there is none), result, its `pgnResult` token and
|
||||||
timestamps, players, and move count. `nextCursor` is present only when another
|
`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
|
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
|
to a user who created it while authenticated or claimed a slot with a first
|
||||||
move; the association survives later player reconfiguration.
|
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",
|
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||||
"result": "white_wins",
|
"result": "white_wins",
|
||||||
"pgnResult": "1-0",
|
"pgnResult": "1-0",
|
||||||
|
"termination": "checkmate",
|
||||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||||
"moveCount": 7,
|
"moveCount": 7,
|
||||||
|
|||||||
+74
-11
@@ -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
|
- Verbose mode for detailed API request/response inspection
|
||||||
- Long-polling support for real-time game updates
|
- Long-polling support for real-time game updates
|
||||||
|
|
||||||
## Replay Foundation
|
## Replay
|
||||||
|
|
||||||
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
||||||
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
||||||
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
|
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
|
`games`, `pgn` and `replay` commands use them. A replay renders the stored
|
||||||
planned, see [Replay Implementation Tasks](./todo.md).
|
FEN of each ply; the client never computes a move.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
```bash
|
```bash
|
||||||
@@ -44,6 +44,7 @@ The prompt dynamically updates to show context:
|
|||||||
- `chess [username - gameId]` - User in active game
|
- `chess [username - gameId]` - User in active game
|
||||||
- `chess [username - gameId] White` - Shows player color
|
- `chess [username - gameId] White` - Shows player color
|
||||||
- Turn indicator with player type (h=human, c=computer)
|
- Turn indicator with player type (h=human, c=computer)
|
||||||
|
- `chess [...] replay 12/40` - Replaying a stored game at ply 12 of 40
|
||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
@@ -180,19 +181,76 @@ repetition.` or `Black wins by resignation.`; a pending offer is shown as
|
|||||||
`White offers a draw`.
|
`White offers a draw`.
|
||||||
|
|
||||||
#### `games` / `g`
|
#### `games` / `g`
|
||||||
List your stored games, newest first, 20 per page (requires login). The
|
List your stored games, newest first, 20 per page (requires login), numbered
|
||||||
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
|
for `replay <#>`. The result column is the PGN token (`1-0`, `0-1`,
|
||||||
|
`1/2-1/2`, `*`).
|
||||||
```
|
```
|
||||||
chess > games # Newest games
|
chess > games # Newest games
|
||||||
chess > games more # Next page
|
chess > games more # Next page
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `pgn`
|
#### `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 <path>`. During a replay the default is the replayed game up to the
|
||||||
|
current ply.
|
||||||
```
|
```
|
||||||
chess > pgn # Current game, every ply
|
chess > pgn # Current game, every ply
|
||||||
chess > pgn 10 # Current game, first 10 plies
|
chess > pgn 10 # Current game, first 10 plies
|
||||||
chess > pgn <gameId> [ply] # Another game
|
chess > pgn <gameId> [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
|
### Debug Commands
|
||||||
@@ -285,10 +343,14 @@ ASCII board with colored pieces:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Move History
|
### 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
|
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
|
## Workflows
|
||||||
|
|
||||||
@@ -319,8 +381,9 @@ History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
|
|||||||
### Limitations
|
### Limitations
|
||||||
- No move validation in client (server validates)
|
- No move validation in client (server validates)
|
||||||
- No algebraic notation input (UCI format only)
|
- 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
|
- No persistent authentication across restarts
|
||||||
|
- `auto` runs to the end of the line; it cannot be interrupted
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -221,6 +221,10 @@ go test ./...
|
|||||||
|
|
||||||
# Concurrency checks for the state/persistence boundary
|
# Concurrency checks for the state/persistence boundary
|
||||||
go test -race ./internal/server/storage ./internal/server/service
|
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
|
## 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)
|
- No push-based game updates (30-second long-polling is used)
|
||||||
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
||||||
- Database history has no automatic retention policy
|
- 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
|
- REST API only
|
||||||
+71
-33
@@ -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] 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
|
- [x] Pawn promotion in the web client (piece picker) and a specific API error
|
||||||
for a promotion without its piece.
|
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
|
## 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. |
|
| 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). |
|
| 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`. |
|
| 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=<id>&ply=<n>`, 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
|
## Phase R1 — Notation Core (server) — done
|
||||||
|
|
||||||
@@ -88,29 +93,43 @@ Test gate:
|
|||||||
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
||||||
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
|
- [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)
|
- [x] **My games** button in the top bar opening a drawer: mini-board from
|
||||||
opening a panel: mini-board from `finalFen`, result, date, opponent or
|
`finalFen`, players (the viewer's side as "You"), result and termination,
|
||||||
engine level, move count; loading, empty, error, and "load more" states.
|
date, move count; status and colour filters; loading, empty, error (with
|
||||||
- [ ] Replay mode entered from the panel or a deep link
|
retry), and "load more" (cursor) states. Signed-out visitors see the games
|
||||||
`?replay=<gameId>&ply=<n>`; `history.pushState` so browser back/forward
|
played on this device (D9) and a sign-in note.
|
||||||
restores the list and the ply.
|
- [x] Replay mode entered from the drawer, the live pane's **Replay** button,
|
||||||
- [ ] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`;
|
or a deep link `?replay=<gameId>&ply=<n>`; `history.pushState` per view
|
||||||
clickable SAN move list; autoplay with speed selector and pause; board flip;
|
(D11), so browser back/forward restores the list and the ply.
|
||||||
current ply announced through an `aria-live` region.
|
- [x] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`,
|
||||||
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
`Space` (autoplay), `F` (flip), `Esc` (leave); clickable SAN move list with
|
||||||
computes moves.
|
the current ply marked; autoplay with a speed selector and pause; board
|
||||||
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
flip, defaulting to the viewer's side; current ply announced through an
|
||||||
from R2 as a `Blob`, saved through a temporary `<a download>`). The live
|
`aria-live` region.
|
||||||
view's "Copy PGN" button copies UCI moves today; point it at the PGN
|
- [x] Position rendering assigns the stored FEN for the ply; the browser never
|
||||||
endpoint too.
|
computes moves. The mated king and the last move are marked as in live play.
|
||||||
- [ ] Show SAN in the live move list (from the history, or a `san` field on
|
- [x] Export at the current ply: copy FEN, copy PGN (`?ply=N`, or the full
|
||||||
the live game response).
|
export at the last ply), download PGN (fetched as a `Blob`, saved through a
|
||||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
temporary `<a download>`; the host's iframe sandbox needs
|
||||||
and player-configuration controls; **Back to live game** restores them.
|
`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:
|
Constraints from the host's security headers:
|
||||||
|
|
||||||
@@ -128,20 +147,39 @@ Constraints from the host's security headers:
|
|||||||
|
|
||||||
Test gate:
|
Test gate:
|
||||||
|
|
||||||
- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a
|
- [x] Playwright (Chromium) end-to-end against server and PostgreSQL
|
||||||
short game, open My games, step backward and forward, autoplay to the end,
|
(`test/e2e`, run by the `e2e` CI job): play a short game, open My games,
|
||||||
and compare exported FEN/PGN with the API.
|
step backward and forward with buttons and keys, autoplay to the end,
|
||||||
- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion
|
compare the exported FEN and PGN with the API, download, copy a link,
|
||||||
disables autoplay animation.
|
browser back through replay and list; a deep link to a checkmate; the
|
||||||
- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`.
|
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]`.
|
- [x] `games` (list with cursor paging, numbered) and `pgn [gameId] [ply]`.
|
||||||
- [ ] `replay <gameId|index>`, `next`, `prev`, `first`, `last`, `goto <ply>`,
|
- [x] `replay <gameId|index>` (index into the last `games` page), `next`
|
||||||
`auto <ms>`, `fen`, `pgn save <path>`.
|
`prev` (`>` `<`, with a count), `first`, `last`, `goto <ply>`, `auto [ms]`,
|
||||||
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
`fen`, `pgn [save <path>]` (the replayed game up to the current ply; in
|
||||||
or configuration while replaying.
|
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 <ply>/<plies>`; `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)
|
## Phase R5 — Curated Archive (later)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type GameResponse struct {
|
|||||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||||
Concession *Concession `json:"concession,omitempty"`
|
Concession *Concession `json:"concession,omitempty"`
|
||||||
Moves []string `json:"moves"`
|
Moves []string `json:"moves"`
|
||||||
|
SAN []string `json:"san"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -129,6 +130,7 @@ type GameHistoryResponse struct {
|
|||||||
Termination string `json:"termination,omitempty"`
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
|
Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins")
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
Moves []HistoryMove `json:"moves"`
|
Moves []HistoryMove `json:"moves"`
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,7 @@ type GameSummary struct {
|
|||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
PGNResult string `json:"pgnResult"`
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ func (r *Registry) registerGameCommands() {
|
|||||||
r.Register(&Command{
|
r.Register(&Command{
|
||||||
Name: "pgn",
|
Name: "pgn",
|
||||||
ShortName: "",
|
ShortName: "",
|
||||||
Description: "Print a stored game as PGN",
|
Description: "Print or save a stored game as PGN",
|
||||||
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
|
Usage: "pgn [gameId] [ply] [save <path>] (default: current or replayed game)",
|
||||||
Handler: pgnHandler,
|
Handler: pgnHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -520,6 +520,9 @@ func undoHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showBoardHandler(s *session.Session, args []string) error {
|
func showBoardHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay != nil {
|
||||||
|
return showReplayPosition(s)
|
||||||
|
}
|
||||||
gameID := s.GetCurrentGame()
|
gameID := s.GetCurrentGame()
|
||||||
if gameID == "" {
|
if gameID == "" {
|
||||||
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
||||||
@@ -692,19 +695,21 @@ func gamesHandler(s *session.Session, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.GamesCursor = resp.NextCursor
|
s.GamesCursor = resp.NextCursor
|
||||||
|
s.GamesPage = resp.Games
|
||||||
if len(resp.Games) == 0 {
|
if len(resp.Games) == 0 {
|
||||||
display.Println(display.Yellow, "No stored games")
|
display.Println(display.Yellow, "No stored games")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
fmt.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||||
for _, g := range resp.Games {
|
for i, g := range resp.Games {
|
||||||
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
|
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,
|
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
||||||
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
display.Println(display.Cyan, "\nReplay one: replay <#> or replay <gameId>")
|
||||||
if resp.NextCursor != "" {
|
if resp.NextCursor != "" {
|
||||||
display.Println(display.Cyan, "\nMore games: games more")
|
display.Println(display.Cyan, "More games: games more")
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// 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 <path>" writes the
|
||||||
|
// export to a file instead of printing it.
|
||||||
func pgnHandler(s *session.Session, args []string) error {
|
func pgnHandler(s *session.Session, args []string) error {
|
||||||
|
path, args, err := pgnSaveArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
gameID, ply := s.CurrentGame, -1
|
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 {
|
for _, arg := range args {
|
||||||
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
||||||
ply = n
|
ply = n
|
||||||
@@ -737,6 +754,13 @@ func pgnHandler(s *session.Session, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.Println()
|
||||||
fmt.Print(text)
|
fmt.Print(text)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func NewRegistry(s *session.Session) *Registry {
|
|||||||
|
|
||||||
// Register all commands
|
// Register all commands
|
||||||
r.registerGameCommands()
|
r.registerGameCommands()
|
||||||
|
r.registerReplayCommands()
|
||||||
r.registerAuthCommands()
|
r.registerAuthCommands()
|
||||||
r.registerDebugCommands()
|
r.registerDebugCommands()
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ func (r *Registry) Execute(input string) {
|
|||||||
display.Println(display.Reset, "Type 'help' for available commands")
|
display.Println(display.Reset, "Type 'help' for available commands")
|
||||||
return
|
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
|
// Set verbose mode in client if session supports it
|
||||||
if cl, ok := r.session.GetClient().(*api.Client); ok {
|
if cl, ok := r.session.GetClient().(*api.Client); ok {
|
||||||
@@ -132,6 +137,18 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
{"pgn", "", ""},
|
{"pgn", "", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replayCommands := []cmdInfo{
|
||||||
|
{"replay", "", ""},
|
||||||
|
{"next", ">", ""},
|
||||||
|
{"prev", "<", ""},
|
||||||
|
{"first", "", ""},
|
||||||
|
{"last", "", ""},
|
||||||
|
{"goto", "", ""},
|
||||||
|
{"auto", "", ""},
|
||||||
|
{"fen", "", ""},
|
||||||
|
{"live", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
authCommands := []cmdInfo{
|
authCommands := []cmdInfo{
|
||||||
{"register", "r", ""},
|
{"register", "r", ""},
|
||||||
{"login", "l", ""},
|
{"login", "l", ""},
|
||||||
@@ -163,6 +180,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printCommandGroup("Game Commands", gameCommands)
|
printCommandGroup("Game Commands", gameCommands)
|
||||||
|
printCommandGroup("Replay Commands", replayCommands)
|
||||||
printCommandGroup("Auth Commands", authCommands)
|
printCommandGroup("Auth Commands", authCommands)
|
||||||
printCommandGroup("Utility Commands", utilCommands)
|
printCommandGroup("Utility Commands", utilCommands)
|
||||||
|
|
||||||
|
|||||||
@@ -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 <gameId|index> (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 <ply>",
|
||||||
|
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 <gameId|index>'")
|
||||||
|
|
||||||
|
// 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|index>")
|
||||||
|
}
|
||||||
|
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 <ply>, auto [ms], fen, pgn [save <path>], 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 <ply> (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 <path> [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 <path>")
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package session
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/lixenwraith/chess/internal/client/api"
|
"github.com/lixenwraith/chess/internal/client/api"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/replay"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session maintains client state and configuration
|
// Session maintains client state and configuration
|
||||||
@@ -12,9 +13,13 @@ type Session struct {
|
|||||||
AuthToken string
|
AuthToken string
|
||||||
Username string
|
Username string
|
||||||
LastMoveCount int
|
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 <index>"
|
||||||
Client *api.Client
|
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
|
// Game state for prompt
|
||||||
CurrentGameState *api.GameResponse
|
CurrentGameState *api.GameResponse
|
||||||
PlayerColor string // "w", "b", or ""
|
PlayerColor string // "w", "b", or ""
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ type UndoRequest struct {
|
|||||||
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
||||||
// reports what a draw request did ("offered", "accepted", "declined") and is
|
// reports what a draw request did ("offered", "accepted", "declined") and is
|
||||||
// set only on responses to that request. Concession is the game's first
|
// 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 {
|
type GameResponse struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
FEN string `json:"fen"`
|
FEN string `json:"fen"`
|
||||||
@@ -53,6 +55,7 @@ type GameResponse struct {
|
|||||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||||
Concession *Concession `json:"concession,omitempty"`
|
Concession *Concession `json:"concession,omitempty"`
|
||||||
Moves []string `json:"moves"`
|
Moves []string `json:"moves"`
|
||||||
|
SAN []string `json:"san"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -113,13 +116,15 @@ type HistoryMove struct {
|
|||||||
|
|
||||||
// GameSummary is intentionally sufficient for a client-side game picker; the
|
// GameSummary is intentionally sufficient for a client-side game picker; the
|
||||||
// full move list remains on the per-game history endpoint. FinalFEN is 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 {
|
type GameSummary struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
InitialFEN string `json:"initialFen"`
|
InitialFEN string `json:"initialFen"`
|
||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
PGNResult string `json:"pgnResult"`
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
|
|||||||
@@ -93,10 +93,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
|||||||
TimeZone: "UTC",
|
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{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "*",
|
||||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
AllowHeaders: "Origin,Content-Type,Accept,Authorization,If-None-Match",
|
||||||
|
ExposeHeaders: "ETag,Content-Disposition",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Health check (no rate limit)
|
// Health check (no rate limit)
|
||||||
|
|||||||
@@ -674,6 +674,7 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
|
|||||||
State: g.State.String(),
|
State: g.State.String(),
|
||||||
Termination: string(g.Termination),
|
Termination: string(g.Termination),
|
||||||
Moves: g.Moves,
|
Moves: g.Moves,
|
||||||
|
SAN: notateLine(g.FENs, g.Moves),
|
||||||
Players: core.PlayersResponse{
|
Players: core.PlayersResponse{
|
||||||
White: g.WhitePlayer,
|
White: g.WhitePlayer,
|
||||||
Black: g.BlackPlayer,
|
Black: g.BlackPlayer,
|
||||||
@@ -721,6 +722,27 @@ func (p *Processor) Close() error {
|
|||||||
return errors.Join(queueErr, engineErr)
|
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
|
// rulesResult is the rules core's verdict on a move, computed alongside the
|
||||||
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
||||||
// core can be trusted, or fixed, before it validates anything.
|
// core can be trusted, or fixed, before it validates anything.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package processor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lixenwraith/chess/internal/server/chess"
|
"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) {
|
func TestAdjudicateDraw(t *testing.T) {
|
||||||
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
||||||
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
|
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game
|
|||||||
InitialFEN: record.InitialFEN,
|
InitialFEN: record.InitialFEN,
|
||||||
Result: record.Result,
|
Result: record.Result,
|
||||||
PGNResult: chess.ResultToken(record.Result),
|
PGNResult: chess.ResultToken(record.Result),
|
||||||
|
Termination: record.Termination,
|
||||||
StartTimeUTC: record.StartTimeUTC,
|
StartTimeUTC: record.StartTimeUTC,
|
||||||
EndTimeUTC: record.EndTimeUTC,
|
EndTimeUTC: record.EndTimeUTC,
|
||||||
MoveCount: record.MoveCount,
|
MoveCount: record.MoveCount,
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ func TestUserGamesCursorAndFilters(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("finished games = %+v", page.Games)
|
||||||
}
|
}
|
||||||
for _, bad := range []UserGamesOptions{
|
for _, bad := range []UserGamesOptions{
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,86 +3,194 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Chess Game</title>
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Chess</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-mode="live">
|
||||||
<div class="outer-container">
|
<div class="app">
|
||||||
<div class="container">
|
<header class="topbar">
|
||||||
<main>
|
<div class="brand" aria-label="Chess"><span class="brand-mark" aria-hidden="true">♞</span><span class="brand-name">Chess</span></div>
|
||||||
<div class="game-area">
|
<div class="health" aria-label="Server status">
|
||||||
<div class="board-container">
|
<span class="dot" id="server-dot" data-status="unknown" title="Server: unknown" role="img" aria-label="Server: unknown"></span>
|
||||||
<div class="board-wrapper">
|
<span class="dot" id="storage-dot" data-status="unknown" title="Storage: unknown" role="img" aria-label="Storage: unknown"></span>
|
||||||
<div class="coordinates top">
|
</div>
|
||||||
<span>a</span><span>b</span><span>c</span><span>d</span>
|
<div class="topbar-spacer"></div>
|
||||||
<span>e</span><span>f</span><span>g</span><span>h</span>
|
<button id="games-btn" class="btn btn-ghost btn-sm" type="button" title="Stored games">
|
||||||
</div>
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
<div class="coordinates left">
|
<span>My games</span>
|
||||||
<span>8</span><span>7</span><span>6</span><span>5</span>
|
</button>
|
||||||
<span>4</span><span>3</span><span>2</span><span>1</span>
|
<button id="auth-btn" class="btn btn-ghost btn-sm" type="button" data-status="anonymous" title="Sign in or register">
|
||||||
</div>
|
<span class="dot" id="auth-dot" data-status="anonymous" aria-hidden="true"></span>
|
||||||
<div id="board"></div>
|
<span id="auth-label">Sign in</span>
|
||||||
</div>
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section class="board-area" aria-label="Board">
|
||||||
|
<div class="board-wrapper">
|
||||||
|
<div class="coordinates files" aria-hidden="true"></div>
|
||||||
|
<div class="coordinates ranks" aria-hidden="true"></div>
|
||||||
|
<div id="board" role="grid" aria-label="Chess board"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toast-host"><div id="toast" class="toast" role="status" aria-live="polite"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel" aria-label="Game panel">
|
||||||
|
<!-- Live game -->
|
||||||
|
<div class="pane pane-live" id="pane-live">
|
||||||
|
<div class="turn-strip" id="turn-strip">
|
||||||
|
<span class="dot turn-dot" id="turn-dot" data-status="idle" aria-hidden="true"></span>
|
||||||
|
<span class="turn-text" id="turn-text">No game</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls" role="group" aria-label="Game controls">
|
||||||
|
<button id="new-game-btn" class="btn btn-primary" type="button">New game</button>
|
||||||
|
<button id="undo-btn" class="btn btn-secondary" type="button" disabled>Undo</button>
|
||||||
|
<button id="draw-btn" class="btn btn-secondary" type="button" title="Offer a draw" disabled>Draw</button>
|
||||||
|
<button id="resign-btn" class="btn btn-secondary" type="button" title="Resign this game" disabled>Resign</button>
|
||||||
|
</div>
|
||||||
|
<div class="moves" id="live-moves-box">
|
||||||
|
<div class="move-grid" id="live-moves" aria-label="Moves"></div>
|
||||||
|
<p class="moves-empty" id="live-moves-empty">Start a game to play Stockfish, or open <strong>My games</strong> to replay one.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" role="group" aria-label="Export">
|
||||||
|
<button id="copy-fen-btn" class="btn btn-tool" type="button" title="Copy the current position as FEN" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>FEN</span>
|
||||||
|
</button>
|
||||||
|
<button id="copy-pgn-btn" class="btn btn-tool" type="button" title="Copy the game as PGN" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>PGN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-live-btn" class="btn btn-tool" type="button" title="Replay this game from the start" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||||||
|
<span>Replay</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="info-panel">
|
<!-- Replay -->
|
||||||
<div class="status-indicators">
|
<div class="pane pane-replay" id="pane-replay">
|
||||||
<div class="indicator" id="server-indicator" data-tooltip="Server">
|
<div class="replay-head">
|
||||||
<span class="light" data-status="unknown">●</span>
|
<div class="replay-title-row">
|
||||||
</div>
|
<span class="badge" id="replay-badge">Replay</span>
|
||||||
<div class="indicator" id="storage-indicator" data-tooltip="Storage">
|
<span class="replay-result" id="replay-result"></span>
|
||||||
<span class="light" data-status="unknown">●</span>
|
<span class="replay-ply" id="replay-ply" aria-hidden="true"></span>
|
||||||
</div>
|
|
||||||
<div class="indicator" id="turn-indicator" data-tooltip="Turn">
|
|
||||||
<span class="light turn-light" data-status="white">●</span>
|
|
||||||
</div>
|
|
||||||
<div class="indicator auth-indicator" id="auth-indicator" data-tooltip="Auth">
|
|
||||||
<span class="light" data-status="anonymous">●</span>
|
|
||||||
</div>
|
|
||||||
<div class="error-flash-overlay" id="error-flash-overlay">
|
|
||||||
<div class="error-flash-message" id="error-flash-message"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="replay-players" id="replay-players"></div>
|
||||||
|
<div class="replay-meta" id="replay-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="moves" id="replay-moves-box">
|
||||||
<div class="controls">
|
<div class="move-grid move-grid-replay" id="replay-moves" aria-label="Moves"></div>
|
||||||
<button id="new-game-btn" class="btn btn-primary">New</button>
|
<p class="moves-empty" id="replay-moves-empty">Loading…</p>
|
||||||
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
|
|
||||||
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
|
|
||||||
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="replay-nav" role="group" aria-label="Replay controls">
|
||||||
<div class="move-history-container">
|
<button id="replay-first" class="btn btn-nav" type="button" title="First position (Home)" aria-label="First position">
|
||||||
<button class="copy-btn" id="copy-history" title="Copy PGN">
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5v14M18 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
</button>
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
<button id="replay-prev" class="btn btn-nav" type="button" title="Previous move (←)" aria-label="Previous move">
|
||||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" fill="none" stroke="currentColor" stroke-width="2"/>
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
</svg>
|
</button>
|
||||||
|
<button id="replay-play" class="btn btn-nav btn-play" type="button" title="Autoplay (Space)" aria-label="Autoplay" aria-pressed="false">
|
||||||
|
<svg class="icon icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||||||
|
<svg class="icon icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-next" class="btn btn-nav" type="button" title="Next move (→)" aria-label="Next move">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-last" class="btn btn-nav" type="button" title="Last position (End)" aria-label="Last position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 5v14M6 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<label class="speed" title="Autoplay speed">
|
||||||
|
<span class="visually-hidden">Autoplay speed</span>
|
||||||
|
<select id="replay-speed">
|
||||||
|
<option value="2000">0.5×</option>
|
||||||
|
<option value="1000" selected>1×</option>
|
||||||
|
<option value="500">2×</option>
|
||||||
|
<option value="250">4×</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="replay-flip" class="btn btn-nav" type="button" title="Flip the board (F)" aria-label="Flip the board" aria-pressed="false">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v12M7 16l-3-3M7 16l3-3M17 20V8M17 8l-3 3M17 8l3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="move-history" class="move-history">
|
|
||||||
<div class="move-grid" id="move-grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar" role="group" aria-label="Export at this position">
|
||||||
|
<button id="replay-copy-fen" class="btn btn-tool" type="button" title="Copy the FEN of this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>FEN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-copy-pgn" class="btn btn-tool" type="button" title="Copy the PGN up to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>PGN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-download" class="btn btn-tool" type="button" title="Download the PGN up to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 19h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-link" class="btn btn-tool" type="button" title="Copy a link to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span>Link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button id="replay-exit" class="btn btn-secondary btn-block" type="button">Back to live game</button>
|
||||||
|
<div id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
</aside>
|
<!-- My games drawer -->
|
||||||
</main>
|
<div id="games-overlay" class="modal-overlay">
|
||||||
|
<div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="games-title">My games</h2>
|
||||||
|
<button id="games-close" class="btn btn-icon" type="button" aria-label="Close">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="games-filters" id="games-filters">
|
||||||
|
<div class="segmented" role="group" aria-label="Status">
|
||||||
|
<button type="button" class="seg active" data-filter="status" data-value="">All</button>
|
||||||
|
<button type="button" class="seg" data-filter="status" data-value="ongoing">Ongoing</button>
|
||||||
|
<button type="button" class="seg" data-filter="status" data-value="finished">Finished</button>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="group" aria-label="Your colour">
|
||||||
|
<button type="button" class="seg active" data-filter="color" data-value="">Any</button>
|
||||||
|
<button type="button" class="seg" data-filter="color" data-value="white">White</button>
|
||||||
|
<button type="button" class="seg" data-filter="color" data-value="black">Black</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="games-body">
|
||||||
|
<section class="games-section" id="account-games-section">
|
||||||
|
<h3 class="games-heading" id="account-games-heading">Your games</h3>
|
||||||
|
<p class="games-note" id="games-signin-note">Sign in to keep your games and replay them here. Games played anonymously are kept for 24 hours.</p>
|
||||||
|
<ul class="games-list" id="games-list"></ul>
|
||||||
|
<p class="games-status" id="games-status" role="status"></p>
|
||||||
|
<button id="games-more" class="btn btn-secondary btn-block" type="button">Load more</button>
|
||||||
|
</section>
|
||||||
|
<section class="games-section" id="local-games-section">
|
||||||
|
<h3 class="games-heading">On this device</h3>
|
||||||
|
<p class="games-note">Games played here without signing in; the server keeps them for 24 hours after the last move.</p>
|
||||||
|
<ul class="games-list" id="local-games-list"></ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth Modal -->
|
<!-- Auth Modal -->
|
||||||
<div id="auth-modal-overlay" class="modal-overlay">
|
<div id="auth-modal-overlay" class="modal-overlay">
|
||||||
<div class="modal auth-modal">
|
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||||
<div class="auth-tabs">
|
<h2 id="auth-title" class="visually-hidden">Account</h2>
|
||||||
<button class="auth-tab active" data-tab="login">Login</button>
|
<div class="auth-tabs" role="tablist">
|
||||||
<button class="auth-tab" data-tab="register">Register</button>
|
<button class="auth-tab active" type="button" role="tab" data-tab="login" aria-selected="true">Sign in</button>
|
||||||
|
<button class="auth-tab" type="button" role="tab" data-tab="register" aria-selected="false">Register</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="auth-modal-message" class="modal-message"></div>
|
<div id="auth-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<div id="login-form" class="auth-form">
|
<div id="login-form" class="auth-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="login-identifier">Username or Email</label>
|
<label for="login-identifier">Username or email</label>
|
||||||
<input type="text" id="login-identifier" autocomplete="username">
|
<input type="text" id="login-identifier" autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -90,13 +198,12 @@
|
|||||||
<input type="password" id="login-password" autocomplete="current-password">
|
<input type="password" id="login-password" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="login-submit-btn" class="btn btn-primary">Login</button>
|
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
|
||||||
<button id="auth-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register Form -->
|
<div id="register-form" class="auth-form hidden">
|
||||||
<div id="register-form" class="auth-form" style="display: none;">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="register-username">Username</label>
|
<label for="register-username">Username</label>
|
||||||
<input type="text" id="register-username" autocomplete="username">
|
<input type="text" id="register-username" autocomplete="username">
|
||||||
@@ -106,12 +213,12 @@
|
|||||||
<input type="email" id="register-email" autocomplete="email">
|
<input type="email" id="register-email" autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="register-password">Password (min 8 chars)</label>
|
<label for="register-password">Password (8+ characters, a letter and a number)</label>
|
||||||
<input type="password" id="register-password" autocomplete="new-password">
|
<input type="password" id="register-password" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="register-submit-btn" class="btn btn-primary">Register</button>
|
<button id="register-submit-btn" class="btn btn-primary" type="button">Register</button>
|
||||||
<button id="auth-cancel-btn-2" class="btn btn-secondary">Cancel</button>
|
<button id="auth-cancel-btn-2" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,8 +231,8 @@
|
|||||||
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
|
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="logout-btn" class="btn btn-primary">Log out</button>
|
<button id="logout-btn" class="btn btn-primary" type="button">Sign out</button>
|
||||||
<button id="account-cancel-btn" class="btn btn-secondary">Close</button>
|
<button id="account-cancel-btn" class="btn btn-secondary" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<details class="danger-zone" id="delete-account">
|
<details class="danger-zone" id="delete-account">
|
||||||
<summary>Delete account</summary>
|
<summary>Delete account</summary>
|
||||||
@@ -137,40 +244,40 @@
|
|||||||
<input type="password" id="delete-password" autocomplete="current-password">
|
<input type="password" id="delete-password" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="delete-account-btn" class="btn btn-danger">Delete account</button>
|
<button id="delete-account-btn" class="btn btn-danger" type="button">Delete account</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overlay Modal -->
|
<!-- New Game Modal -->
|
||||||
<div id="modal-overlay" class="modal-overlay">
|
<div id="modal-overlay" class="modal-overlay">
|
||||||
<div class="modal">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="new-game-title">
|
||||||
<h2>New Game</h2>
|
<h2 id="new-game-title">New game</h2>
|
||||||
<div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
<div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
<div class="form-group form-row">
|
<div class="form-group form-row">
|
||||||
<span class="group-label" id="player-color-label">Your Color</span>
|
<span class="group-label" id="player-color-label">Your colour</span>
|
||||||
<div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
|
<div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
|
||||||
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
|
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
|
||||||
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
|
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="computer-level">Computer Level: <span id="level-value">10</span></label>
|
<label for="computer-level">Computer level: <span id="level-value">10</span></label>
|
||||||
<input type="range" id="computer-level" min="0" max="20" value="10">
|
<input type="range" id="computer-level" min="0" max="20" value="10">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="search-time">Search Time (ms): <span id="time-value">1000</span></label>
|
<label for="search-time">Search time: <span id="time-value">1000</span> ms</label>
|
||||||
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
|
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="starting-fen">Starting Position (FEN)</label>
|
<label for="starting-fen">Starting position (FEN)</label>
|
||||||
<textarea id="starting-fen" class="fen-input" rows="2"
|
<textarea id="starting-fen" class="fen-input" rows="2" spellcheck="false"
|
||||||
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
|
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
|
<button id="start-game-btn" class="btn btn-primary" type="button">Start game</button>
|
||||||
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,8 +287,8 @@
|
|||||||
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
|
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
|
||||||
<h2 id="resign-title">Resign this game?</h2>
|
<h2 id="resign-title">Resign this game?</h2>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
|
<button id="resign-confirm-btn" class="btn btn-primary" type="button">Resign</button>
|
||||||
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="resign-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,13 +298,13 @@
|
|||||||
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
|
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
|
||||||
<h2 id="promotion-title">Promote to</h2>
|
<h2 id="promotion-title">Promote to</h2>
|
||||||
<div class="promotion-choices">
|
<div class="promotion-choices">
|
||||||
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
|
<button class="promotion-choice" type="button" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
|
||||||
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
|
<button class="promotion-choice" type="button" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
|
||||||
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
|
<button class="promotion-choice" type="button" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
|
||||||
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
|
<button class="promotion-choice" type="button" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="promotion-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+39
-1
@@ -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.
|
It drops the test tables on exit, so it's preferred for clean testing.
|
||||||
Can be used for all the tests.
|
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
|
```bash
|
||||||
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
|
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
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
||||||
|
|
||||||
## 1. API Functionality Tests (`test-api.sh`)
|
## 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
|
game whose row is deleted with
|
||||||
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
|
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
|
||||||
stays healthy).
|
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`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } : {},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
|
|||||||
export CHESS_DSN="$CHESS_TEST_DSN"
|
export CHESS_DSN="$CHESS_TEST_DSN"
|
||||||
PID_FILE="/tmp/chess-server_test.pid"
|
PID_FILE="/tmp/chess-server_test.pid"
|
||||||
API_PORT=${API_PORT:-8080}
|
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_LEVEL=${LOG_LEVEL:-debug}
|
||||||
LOG_HTTP=${LOG_HTTP:-true}
|
LOG_HTTP=${LOG_HTTP:-true}
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ echo ""
|
|||||||
echo "Configuration:"
|
echo "Configuration:"
|
||||||
echo " Executable: $CHESS_SERVER_EXEC"
|
echo " Executable: $CHESS_SERVER_EXEC"
|
||||||
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
|
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 " Mode: Development (relaxed rate limits; PostgreSQL storage)"
|
||||||
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
|
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
|
||||||
echo " Purpose: Backend for chess-server tests"
|
echo " Purpose: Backend for chess-server tests"
|
||||||
@@ -111,10 +113,13 @@ echo "Starting server..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start chess-server in foreground with dev mode and storage
|
# 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" \
|
"$CHESS_SERVER_EXEC" \
|
||||||
-dev \
|
-dev \
|
||||||
-log-level "$LOG_LEVEL" \
|
-log-level "$LOG_LEVEL" \
|
||||||
-log-http="$LOG_HTTP" \
|
-log-http="$LOG_HTTP" \
|
||||||
-api-port "$API_PORT" \
|
-api-port "$API_PORT" \
|
||||||
|
"${WEB_FLAGS[@]}" \
|
||||||
-pid "$PID_FILE" \
|
-pid "$PID_FILE" \
|
||||||
-pid-lock
|
-pid-lock
|
||||||
Reference in new issue
Block a user