Compare commits

...
4 Commits
Author SHA256 Message Date
lixen 3ab0efee31 v0.16.3 go1.27.3 and dep update
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
2026-10-29 04:08:23 -04:00
lixen a012d07cd8 v0.16.2 PGN site, web client panel update
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
2026-10-20 01:39:53 -04:00
lixen 0461020c46 v0.16.1 web client update: inline favicon, board fills the window, large-font phones
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
2026-10-19 22:45:12 -04:00
lixen 664cf9654e v0.16.0 import PGN, delete games, two-layout web client
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
2026-10-19 21:02:39 -04:00
42 changed files with 2168 additions and 215 deletions

No files matched your search

+95 -2
View File
@@ -1,6 +1,7 @@
package cli package cli
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"flag" "flag"
@@ -26,7 +27,7 @@ const dsnEnv = "CHESS_DSN"
// Run is the entry point for the CLI mini-app // Run is the entry point for the CLI mini-app
func Run(args []string) error { func Run(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, user") return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, game, user")
} }
switch args[0] { switch args[0] {
@@ -40,6 +41,11 @@ func Run(args []string) error {
return runPGN(args[1:]) return runPGN(args[1:])
case "verify": case "verify":
return runVerify(args[1:]) return runVerify(args[1:])
case "game":
if len(args) < 2 {
return fmt.Errorf("game subcommand required: delete, import")
}
return runGame(args[1], args[2:])
case "user": case "user":
if len(args) < 2 { if len(args) < 2 {
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list") return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list")
@@ -209,7 +215,7 @@ func runPGN(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("load game %s: %w", id, err) return fmt.Errorf("load game %s: %w", id, err)
} }
pgn, err := replay.BuildPGN(record, moves, *ply) pgn, err := replay.BuildPGN(record, moves, *ply, "")
if err != nil { if err != nil {
return err return err
} }
@@ -268,6 +274,93 @@ func runVerify(args []string) error {
return nil return nil
} }
func runGame(subcommand string, args []string) error {
switch subcommand {
case "delete":
return runGameDelete(args)
case "import":
return runGameImport(args)
default:
return fmt.Errorf("unknown game subcommand: %s", subcommand)
}
}
// runGameDelete removes a stored game and its moves outright, whoever holds
// it. A live copy in a running server keeps playing in memory until it is
// unloaded; its later writes find no row and are dropped.
func runGameDelete(args []string) error {
fs, dsn := newFlagSet("game delete")
gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)")
confirm := fs.Bool("confirm", false, "Confirm deleting the game and its moves")
if err := fs.Parse(args); err != nil {
return err
}
if *gameID == "" {
return errors.New("-gameId is required")
}
if !*confirm {
return errors.New("refusing to delete a game without -confirm")
}
store, err := openStore(*dsn, true)
if err != nil {
return err
}
defer store.Close()
id, err := resolveGameID(store, *gameID)
if err != nil {
return err
}
if err := store.DeleteGames([]string{id}); err != nil {
return fmt.Errorf("delete game: %w", err)
}
if err := store.Flush(context.Background()); err != nil {
return fmt.Errorf("delete game: %w", err)
}
fmt.Printf("Deleted game %s\n", id)
return nil
}
// runGameImport stores a PGN file as a game of the named account, as the
// API's import does: every move is checked by the rules core.
func runGameImport(args []string) error {
fs, dsn := newFlagSet("game import")
file := fs.String("file", "", "PGN file holding one game (required)")
username := fs.String("username", "", "Account that will own the game (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *file == "" || *username == "" {
return errors.New("-file and -username are required")
}
text, err := os.ReadFile(*file)
if err != nil {
return err
}
store, err := openStore(*dsn, true)
if err != nil {
return err
}
defer store.Close()
ownerID, err := lookupUser(store, *username, "")
if err != nil {
return err
}
svc, err := service.New(store, []byte(strings.Repeat("x", 32))) // no tokens are minted here
if err != nil {
return err
}
imported, err := svc.ImportGame(ownerID, string(text))
if err != nil {
return err
}
fmt.Printf("Imported game %s (%d plies) for %s\n", imported.GameID, imported.Plies, *username)
return nil
}
func resolveGameID(store *storage.Store, prefix string) (string, error) { func resolveGameID(store *storage.Store, prefix string) (string, error) {
id, err := store.ResolveGameID(prefix) id, err := store.ResolveGameID(prefix)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+12 -2
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/url"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -62,14 +63,15 @@ func main() {
// Password recovery by email: a transport (SMTP relay, or a directory // Password recovery by email: a transport (SMTP relay, or a directory
// of .eml files for development) and the web client's address, which // of .eml files for development) and the web client's address, which
// the emailed link opens. Without a transport, recovery is off. // the emailed link opens. Without a transport, recovery is off. The
// address's host also names this server in exported PGN (Site).
smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)") smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)")
smtpPort = flag.Int("smtp-port", 25, "SMTP relay port") smtpPort = flag.Int("smtp-port", 25, "SMTP relay port")
smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)") smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)")
smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600") smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600")
mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess <chess@example.com>\"") mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess <chess@example.com>\"")
mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)") mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)")
publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/") publicURL = flag.String("public-url", "", "Web client address: emailed links open it, and its host is the Site of exported games, e.g. https://example.com/chess/web-client/")
// Web UI server flags // Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server") serve = flag.Bool("serve", false, "Enable web UI server")
@@ -178,6 +180,14 @@ func main() {
} }
svc.SetIntegrityMode(integrity) svc.SetIntegrityMode(integrity)
if *publicURL != "" {
u, err := url.Parse(*publicURL)
if err != nil || u.Scheme == "" || u.Host == "" {
log.Fatalf("Invalid -public-url %q: must be absolute (https://host/chess/web-client/)", *publicURL)
}
svc.SetSite(u.Hostname())
}
// Password recovery needs mail and the client's public address. // Password recovery needs mail and the client's public address.
sender, err := mail.Config{ sender, err := mail.Config{
SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile, SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile,
+10 -6
View File
@@ -18,8 +18,9 @@
# Default: ${chessd_home}/jwt.key # Default: ${chessd_home}/jwt.key
# chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is # chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is
# trusted as the client address. Default: none # trusted as the client address. Default: none
# chessd_public_url: The web client's public address, which emailed links # chessd_public_url: The web client's public address: emailed links open
# open. Default: none # it, and its host is the Site of exported PGN.
# Default: none
# chessd_smtp_host: SMTP relay for password-reset email (the mail jail). # chessd_smtp_host: SMTP relay for password-reset email (the mail jail).
# Unset leaves password recovery off. Default: none # Unset leaves password recovery off. Default: none
# chessd_smtp_port: Relay port. Default: 25 # chessd_smtp_port: Relay port. Default: 25
@@ -33,8 +34,8 @@
# Add the following to /etc/rc.conf to enable: # Add the following to /etc/rc.conf to enable:
# chessd_enable="YES" # chessd_enable="YES"
# chessd_trusted_proxies="<address nginx connects from>" # chessd_trusted_proxies="<address nginx connects from>"
# and, for password recovery by email, once the mail jail relays for this one:
# chessd_public_url="https://<site>/chess/web-client/" # chessd_public_url="https://<site>/chess/web-client/"
# and, for password recovery by email, once the mail jail relays for this one:
# chessd_smtp_host="<mail jail address>" # chessd_smtp_host="<mail jail address>"
# chessd_mail_from="Chess <chess@<site>>" # chessd_mail_from="Chess <chess@<site>>"
@@ -105,11 +106,14 @@ chessd_start()
if [ -n "${chessd_trusted_proxies}" ]; then if [ -n "${chessd_trusted_proxies}" ]; then
set -- "$@" -trusted-proxies "${chessd_trusted_proxies}" set -- "$@" -trusted-proxies "${chessd_trusted_proxies}"
fi fi
# Password recovery by email: relay, sender, and the client address the if [ -n "${chessd_public_url}" ]; then
# link opens. The server refuses a relay without the other two. set -- "$@" -public-url "${chessd_public_url}"
fi
# Password recovery by email: relay and sender; the link opens the public
# URL. The server refuses a relay without both and the URL.
if [ -n "${chessd_smtp_host}" ]; then if [ -n "${chessd_smtp_host}" ]; then
set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \ set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \
-mail-from "${chessd_mail_from}" -public-url "${chessd_public_url}" -mail-from "${chessd_mail_from}"
if [ -n "${chessd_smtp_user}" ]; then if [ -n "${chessd_smtp_user}" ]; then
set -- "$@" -smtp-user "${chessd_smtp_user}" set -- "$@" -smtp-user "${chessd_smtp_user}"
secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}" secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}"
+60 -3
View File
@@ -290,6 +290,7 @@ carries its SAN, derived on read from the stored position before it.
"termination": "checkmate", "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",
"origin": "played",
"players": { "players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
"black": {"id": "player-id", "color": 2, "type": 1} "black": {"id": "player-id", "color": 2, "type": 1}
@@ -312,6 +313,8 @@ resignation or agreed draw, in stored-result form (`"result": "black_wins"`);
see [Continuing after a resignation or agreed see [Continuing after a resignation or agreed
draw](#continuing-after-a-resignation-or-agreed-draw). draw](#continuing-after-a-resignation-or-agreed-draw).
`origin` is `played` or `imported`; an imported game also carries the
`event`, `site` and `round` its PGN had.
`result` is omitted while a game is ongoing. Persisted terminal values are `result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN `white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN
token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing), token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing),
@@ -340,11 +343,12 @@ Same visibility, `ETag`, and error responses as the history. `ply` (optional,
0 up to the number of stored plies) exports only the first N plies; the 0 up to the number of stored plies) exports only the first N plies; the
result is then `*`, the `Termination` tag is left out, and the filename ends result is then `*`, the `Termination` tag is left out, and the filename ends
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it `-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
as well as download it. as well as download it. `Site` is the host of the server's `-public-url` (`?`
without one); an imported game keeps its own.
``` ```
[Event "Casual game"] [Event "Casual game"]
[Site "?"] [Site "example.com"]
[Date "2026.09.27"] [Date "2026.09.27"]
[Round "-"] [Round "-"]
[White "alice"] [White "alice"]
@@ -389,7 +393,8 @@ later first-move slot claim, newest first. Requires
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, its `pgnResult` token and or the initial FEN when there is none), result, its `pgnResult` token and
`termination` (as in the history; both omitted while ongoing), timestamps, `termination` (as in the history; both omitted while ongoing), timestamps,
players, and move count. `nextCursor` is present only when another players, move count, `origin` (`played` or `imported`) and, for an imported
game, its `event`. `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.
@@ -410,6 +415,7 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
"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,
"origin": "played",
"players": { "players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
"black": {"id": "player-id", "color": 2, "type": 1} "black": {"id": "player-id", "color": 2, "type": 1}
@@ -422,6 +428,56 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
} }
``` ```
### Import a Game from PGN
`POST /games/import`
Stores one PGN game under the signed-in account, for replay and export.
Requires `Authorization: Bearer <token>` and persistent storage; 5 requests a
minute per IP. The PGN is read strictly: well-formed tag pairs, printable
text, balanced comments and variations (which are dropped, with annotations
and numeric glyphs), one game, at most 64 KiB and 1000 plies. Every move is
replayed by the rules core; an illegal move is refused with its ply. The
result comes from the PGN's result token and must agree with the final
position (a `1-0` after Black mates is refused); the stored termination is
checkmate or stalemate when the position says so, a draw rule when one
applies, otherwise resignation or agreement. `White`, `Black`, `Event`,
`Site`, `Round` and `Date`/`UTCDate`/`UTCTime` tags are kept; `SetUp`/`FEN`
set the starting position.
**Request:**
```json
{"pgn": "[White \"Spassky, Boris\"]\n[Black \"Fischer, Robert\"]\n[Result \"1-0\"]\n\n1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0\n"}
```
**Response (201):**
```json
{"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "moveCount": 6, "result": "white_wins", "pgnResult": "1-0"}
```
Errors: 400 `INVALID_REQUEST` with `details` saying what is wrong with the
PGN, 401 without a token, 429 past the limit, 503 `STORAGE_UNAVAILABLE`.
An imported game has no claimed side: it is listed for its importer with
`"origin": "imported"` and its `event`, is excluded from the `color` filter,
and is never purged as anonymous. Its history and PGN carry the tags.
### Remove a Stored Game
`DELETE /users/me/games/{gameId}`
Takes a stored game off the signed-in user's list. A game the user alone
holds (the only claimant, or its importer) is deleted with its moves, and
unloaded if it was still in memory; a game another user also claims is kept
for them, and only the caller's claim and name are cleared from it.
**Response (200):**
```json
{"removed": "game"}
```
`removed` is `game` or `claim`. Errors: 400 for a malformed ID, 404
`GAME_NOT_FOUND` when the user holds nothing in that game, 503 when storage
is unavailable.
### Make Move ### Make Move
`POST /games/{gameId}/moves` `POST /games/{gameId}/moves`
@@ -603,6 +659,7 @@ Error codes:
- Login: 10 requests/minute/IP - Login: 10 requests/minute/IP
- Account deletion: 5 requests/minute/IP - Account deletion: 5 requests/minute/IP
- Password recovery: 3 requests/minute/IP; reset: 10 requests/minute/IP - Password recovery: 3 requests/minute/IP; reset: 10 requests/minute/IP
- PGN import: 5 requests/minute/IP
Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the
proxy's `X-Real-IP` header, trusted only from addresses listed in proxy's `X-Real-IP` header, trusted only from addresses listed in
+16 -4
View File
@@ -182,11 +182,23 @@ repetition.` or `Black wins by resignation.`; a pending offer is shown as
#### `games` / `g` #### `games` / `g`
List your stored games, newest first, 20 per page (requires login), numbered List your stored games, newest first, 20 per page (requires login), numbered
for `replay <#>`. The result column is the PGN token (`1-0`, `0-1`, for `replay <#>` and `games delete <#>`. The result column is the PGN token
`1/2-1/2`, `*`). (`1-0`, `0-1`, `1/2-1/2`, `*`). Deleting a game you alone hold removes it
with its moves; a game another player also claims is only released from
your list.
``` ```
chess > games # Newest games chess > games # Newest games
chess > games more # Next page chess > games more # Next page
chess > games delete 3 # Remove the third game of the last listing (or an ID)
```
#### `import`
Store a PGN file (one game) under your account, for replay with `replay`.
The server checks every move; tags such as `White`, `Black`, `Event` and
`Date` are kept. Not available in the browser build, which has no files;
the web board imports a pasted PGN instead.
```
chess > import games/fischer-spassky-1972-1.pgn
``` ```
#### `pgn` #### `pgn`
+8
View File
@@ -137,6 +137,14 @@ one-time password-reset link (valid an hour) to hand to someone who cannot
receive the recovery email. CLI-created accounts are identical to site receive the recovery email. CLI-created accounts are identical to site
registrations, and the CLI ignores the registration cap. registrations, and the CLI ignores the registration cap.
A game imported from PGN (`POST /api/games/import`, `db game import`) has
`imported_by` set to its owner, no claims, the PGN's `event`, `site` and
`round`, and both sides human with the tag names as snapshots. It is listed
for its owner, never purged as anonymous, and deleted with its moves when the
owner removes it (`DELETE /api/users/me/games/{id}`) or by `db game delete
-gameId <id|prefix> -confirm`. A player removes a game they share with
another registered player by releasing their own claim instead.
Pending password resets live in `password_resets`: the SHA-256 of the token Pending password resets live in `password_resets`: the SHA-256 of the token
in the emailed link, one per account, deleted when used, expired, or by the in the emailed link, one per account, deleted when used, expired, or by the
hourly cleanup. The token itself is never stored. hourly cleanup. The token itself is never stored.
+1 -1
View File
@@ -120,7 +120,7 @@ file is kept next to it with a timestamp).
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account | | `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key | | `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses | | `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report`, or password recovery by email: `-smtp-host <relay> -mail-from "Chess <chess@example.com>" -public-url https://example.com/chess/web-client/` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) | | `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report -public-url https://example.com/chess/web-client/` (the URL's host is the `Site` of exported PGN), or password recovery by email, which also needs `-public-url`: `-smtp-host <relay> -mail-from "Chess <chess@example.com>"` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) |
Server flags worth knowing: `-max-users` (registration cap, default 100), Server flags worth knowing: `-max-users` (registration cap, default 100),
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`), `-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
+5 -4
View File
@@ -154,7 +154,7 @@ as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user | | `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing | | `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted | | `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_public_url` | empty | The web client's public address, which emailed reset links open (`https://<site>/chess/web-client/`) | | `chessd_public_url` | empty | The web client's public address (`https://<site>/chess/web-client/`): emailed reset links open it, and its host is the `Site` of exported PGN |
| `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off | | `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off |
| `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 | | `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 |
| `chessd_mail_from` | empty | Sender address, e.g. `Chess <chess@<site>>` | | `chessd_mail_from` | empty | Sender address, e.g. `Chess <chess@<site>>` |
@@ -182,7 +182,7 @@ Server flags:
| `-smtp-user`, `-smtp-password-file` | | Relay credentials, optional; the file is mode 0600 and only sent over TLS | | `-smtp-user`, `-smtp-password-file` | | Relay credentials, optional; the file is mode 0600 and only sent over TLS |
| `-mail-from` | | Sender address of server email | | `-mail-from` | | Sender address of server email |
| `-mail-dir` | | Development: write email to `.eml` files here instead of a relay | | `-mail-dir` | | Development: write email to `.eml` files here instead of a relay |
| `-public-url` | | The web client's address that emailed links open; required with a transport | | `-public-url` | | The web client's address: emailed links open it, and its host is the `Site` of exported PGN; required with a transport |
Administrative CLI commands run as `chess` so peer authentication selects the Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell: right role; root may use `su -m` although the account has no shell:
@@ -194,8 +194,9 @@ jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:/
`db` subcommands: `init`, `delete -confirm` (drops the tables), `query` `db` subcommands: `init`, `delete -confirm` (drops the tables), `query`
(games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`, (games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
`verify [-gameId <id|prefix>]` (replays stored games against the rules and `verify [-gameId <id|prefix>]` (replays stored games against the rules and
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete| exits non-zero on a mismatch; run it after an upgrade), `game delete -gameId
set-password|set-hash|set-email|set-username|reset-link|list`. <id|prefix> -confirm`, `game import -file <pgn> -username <owner>`, and `user
add|delete|set-password|set-hash|set-email|set-username|reset-link|list`.
A forgotten password is reset from the jail without mail: A forgotten password is reset from the jail without mail:
+1 -1
View File
@@ -40,7 +40,7 @@ go build ./cmd/chess-client-cli
- `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address - `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address
- `-smtp-host`, `-smtp-port`, `-smtp-user`, `-smtp-password-file`, `-mail-from`: SMTP relay for password-reset email; without a transport, recovery is off - `-smtp-host`, `-smtp-port`, `-smtp-user`, `-smtp-password-file`, `-mail-from`: SMTP relay for password-reset email; without a transport, recovery is off
- `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending - `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending
- `-public-url`: The web client's address that emailed links open (required with a transport) - `-public-url`: The web client's address: emailed links open it, and its host is the `Site` of exported PGN (required with a transport)
### Modes ### Modes
```bash ```bash
+29 -12
View File
@@ -141,9 +141,11 @@ Constraints from the host's security headers:
- `connect-src 'self'`: all requests go to the same origin under `/chess/` - `connect-src 'self'`: all requests go to the same origin under `/chess/`
(the board's default API base, or the host's `data-api`); no CDN or (the board's default API base, or the host's `data-api`); no CDN or
cross-origin API. cross-origin API.
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host - `frame-ancestors 'self'`: the iframe host page must stay on the same
page must stay on the same origin. Downloads from `blob:` URLs and origin. No `X-Frame-Options` beside it: browsers that read
`navigator.clipboard` work in a same-origin frame. `frame-ancestors` ignore the header and warn in the console on every page
load. Downloads from `blob:` URLs and `navigator.clipboard` work in a
same-origin frame.
Test gate: Test gate:
@@ -152,7 +154,10 @@ Test gate:
step backward and forward with buttons and keys, autoplay to the end, step backward and forward with buttons and keys, autoplay to the end,
compare the exported FEN and PGN with the API, download, copy a link, compare the exported FEN and PGN with the API, download, copy a link,
browser back through replay and list; a deep link to a checkmate; the browser back through replay and list; a deep link to a checkmate; the
device list for an anonymous visitor. device list for an anonymous visitor; a PGN import replayed and deleted
from its row; a phone viewport that holds the board and the controls,
with a long replay scrolling inside its move list; a password reset
through the emailed link.
- [x] Keyboard-only navigation and labels: every control is a button with a - [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 name, the move list is a list of buttons, the current ply has
`aria-current` and is announced; `prefers-reduced-motion` disables the `aria-current` and is announced; `prefers-reduced-motion` disables the
@@ -204,14 +209,25 @@ Later for the replay clients:
- [ ] Email verification at registration and on `set-email`; today an - [ ] Email verification at registration and on `set-email`; today an
address is taken as typed, so a typo sends nobody a link. address is taken as typed, so a typo sends nobody a link.
## Phase R5 — Curated Archive (later) ## Phase R5 — Imported and Curated Games
- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site, Done, per user: a signed-in user imports one PGN game (`POST
date, round, names, Elo, ECO, source URL, license, attribution), publication /api/games/import`, **Import PGN** in My games, `import <file>` in the CLI,
state, featured order; no user ownership. `db game import` for an administrator) into their own list. Schema v5 adds
- [ ] `chess-server db archive import <pgn|dir>` using the R1 SAN decoder: `imported_by`, `event`, `site`, `round`; the strict parser
validate every move, import each game in one transaction, idempotent by (`chess.ParsePGN`, fuzzed) refuses anything but one well-formed game, every
content hash, dry-run and per-file summary. move is replayed by the rules core, and the result must agree with the
final position. Imported games are never purged as anonymous and are
deleted by their owner (`DELETE /api/users/me/games/{id}`, the row's delete
button, `games delete`), as are played games the user alone holds; a game
shared with another player is released instead.
Later, a curated archive:
- [ ] No user ownership: publication state, featured order, archive metadata
(Elo, ECO, source URL, license, attribution).
- [ ] Bulk import of a PGN file with many games or a directory, idempotent
by content hash, dry-run and per-file summary.
- [ ] Public, bounded, cacheable list endpoint; replay through the same - [ ] Public, bounded, cacheable list endpoint; replay through the same
history/PGN endpoints. history/PGN endpoints.
- [ ] Verify redistribution rights for every bundled collection. - [ ] Verify redistribution rights for every bundled collection.
@@ -223,7 +239,8 @@ Later for the replay clients:
- [ ] Degraded-mode recovery without a restart (replay missing writes from - [ ] Degraded-mode recovery without a restart (replay missing writes from
memory or fail gameplay closed); today recovery requires a restart. memory or fail gameplay closed); today recovery requires a restart.
- [ ] Live-game restoration after restart (unfinished games are replay-only). - [ ] Live-game restoration after restart (unfinished games are replay-only).
- [ ] Retention and user-initiated deletion or anonymization of games. - [x] User-initiated deletion of games (R5); retention of played games is
the anonymous purge only.
- [ ] Thread request contexts from HTTP handlers into storage calls; today each - [ ] Thread request contexts from HTTP handlers into storage calls; today each
call carries its own deadline. call carries its own deadline.
- [x] Draw and resign flows (D5) for live play. - [x] Draw and resign flows (D5) for live play.
+4 -4
View File
@@ -1,10 +1,10 @@
module github.com/lixenwraith/chess module github.com/lixenwraith/chess
go 1.27.2 go 1.27.3
require ( require (
github.com/go-playground/validator/v10 v10.30.5 github.com/go-playground/validator/v10 v10.30.5
github.com/gofiber/fiber/v2 v2.52.15 github.com/gofiber/fiber/v2 v2.52.16
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
github.com/lixenwraith/auth v0.5.1 github.com/lixenwraith/auth v0.5.1
@@ -25,11 +25,11 @@ require (
github.com/mattn/go-colorable v0.1.16 // indirect github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.31 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/molecule-man/go-brrr v1.3.0 // indirect github.com/molecule-man/go-brrr v1.3.1 // indirect
github.com/philhofer/fwd v1.2.1 // indirect github.com/philhofer/fwd v1.2.1 // indirect
github.com/tinylib/msgp v1.6.5 // indirect github.com/tinylib/msgp v1.6.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.76.0 // indirect github.com/valyala/fasthttp v1.77.0 // indirect
golang.org/x/crypto v0.58.0 // indirect golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sync v0.24.0 // indirect golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
+6 -6
View File
@@ -13,8 +13,8 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk= github.com/gofiber/fiber/v2 v2.52.16 h1:lBemN7sjeTPwEdK/9AIAQ3YpYpzEZefiWI/vQT/6dFU=
github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.16/go.mod h1:dFf1PKExrrSfg/hvoP2yXL35W3bGlzZf71KX+IYzkpg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -39,8 +39,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo= github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU= github.com/molecule-man/go-brrr v1.3.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w=
github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/molecule-man/go-brrr v1.3.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ= github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -54,8 +54,8 @@ github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.76.0 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw= github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= github.com/valyala/fasthttp v1.77.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+15
View File
@@ -254,6 +254,21 @@ func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error)
return &resp, err return &resp, err
} }
// RemoveMyGame takes a stored game off the caller's list: deleted when the
// caller alone holds it, released when another player keeps it.
func (c *Client) RemoveMyGame(gameID string) (*RemoveGameResponse, error) {
var resp RemoveGameResponse
err := c.doRequest("DELETE", "/api/users/me/games/"+gameID, nil, &resp)
return &resp, err
}
// ImportGame stores a PGN game for the caller.
func (c *Client) ImportGame(pgn string) (*ImportGameResponse, error) {
var resp ImportGameResponse
err := c.doRequest("POST", "/api/games/import", &ImportGameRequest{PGN: pgn}, &resp)
return &resp, err
}
func (c *Client) Register(username, password, email string) (*AuthResponse, error) { func (c *Client) Register(username, password, email string) (*AuthResponse, error) {
req := &RegisterRequest{ req := &RegisterRequest{
Username: username, Username: username,
+21
View File
@@ -131,6 +131,10 @@ type GameHistoryResponse struct {
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") Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins")
Origin string `json:"origin,omitempty"`
Event string `json:"event,omitempty"`
Site string `json:"site,omitempty"`
Round string `json:"round,omitempty"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"` Moves []HistoryMove `json:"moves"`
} }
@@ -154,9 +158,26 @@ type GameSummary struct {
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"`
Origin string `json:"origin,omitempty"`
Event string `json:"event,omitempty"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
} }
type ImportGameRequest struct {
PGN string `json:"pgn"`
}
type ImportGameResponse struct {
GameID string `json:"gameId"`
MoveCount int `json:"moveCount"`
Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
}
type RemoveGameResponse struct {
Removed string `json:"removed"` // "game" or "claim"
}
type GameListResponse struct { type GameListResponse struct {
Games []GameSummary `json:"games"` Games []GameSummary `json:"games"`
Limit int `json:"limit"` Limit int `json:"limit"`
+69 -2
View File
@@ -98,8 +98,8 @@ func (r *Registry) registerGameCommands() {
r.Register(&Command{ r.Register(&Command{
Name: "games", Name: "games",
ShortName: "g", ShortName: "g",
Description: "List your stored games, newest first (login required)", Description: "List, or delete, your stored games (login required)",
Usage: "games [more]", Usage: "games [more] | games delete <#|gameId>",
Handler: gamesHandler, Handler: gamesHandler,
Paged: true, Paged: true,
}) })
@@ -113,6 +113,14 @@ func (r *Registry) registerGameCommands() {
Paged: true, Paged: true,
}) })
r.Register(&Command{
Name: "import",
ShortName: "",
Description: "Store a PGN file as one of your games (login required)",
Usage: "import <path>",
Handler: importHandler,
})
r.Register(&Command{ r.Register(&Command{
Name: "poll", Name: "poll",
ShortName: "p", ShortName: "p",
@@ -686,6 +694,9 @@ func gamesHandler(s *session.Session, args []string) error {
if s.AuthToken == "" { if s.AuthToken == "" {
return fmt.Errorf("login required") return fmt.Errorf("login required")
} }
if len(args) > 0 && args[0] == "delete" {
return gamesDeleteHandler(s, args[1:])
}
cursor := "" cursor := ""
if len(args) > 0 && args[0] == "more" { if len(args) > 0 && args[0] == "more" {
if s.GamesCursor == "" { if s.GamesCursor == "" {
@@ -718,6 +729,62 @@ func gamesHandler(s *session.Session, args []string) error {
return nil return nil
} }
// gamesDeleteHandler removes a game from the user's list by its number in
// the last listing or its ID. A game another player also claims is only
// released; one the user alone holds is deleted with its moves.
func gamesDeleteHandler(s *session.Session, args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: games delete <#|gameId>")
}
gameID := args[0]
if index, err := strconv.Atoi(gameID); err == nil {
if index < 1 || index > len(s.GamesPage) {
return fmt.Errorf("index %d is outside the last listing (1-%d)", index, len(s.GamesPage))
}
gameID = s.GamesPage[index-1].GameID
}
resp, err := s.Client.RemoveMyGame(gameID)
if err != nil {
return err
}
if resp.Removed == "claim" {
display.Println(display.Yellow, "Released your side of %s; the other player keeps the game", gameID)
} else {
display.Println(display.Green, "Deleted game %s", gameID)
}
if s.Replay != nil && s.Replay.History.GameID == gameID {
s.Replay = nil
}
if s.CurrentGame == gameID && resp.Removed == "game" {
s.CurrentGame = ""
s.CurrentGameState = nil
s.LastMoveCount = 0
}
return nil
}
// importHandler stores a PGN file (one game) under the user's account and
// makes it the replay.
func importHandler(s *session.Session, args []string) error {
if s.AuthToken == "" {
return fmt.Errorf("login required")
}
if len(args) < 1 {
return fmt.Errorf("usage: import <path>")
}
text, err := readImportFile(args[0])
if err != nil {
return err
}
resp, err := s.Client.ImportGame(text)
if err != nil {
return err
}
display.Println(display.Green, "Imported game %s (%d plies, %s)", resp.GameID, resp.MoveCount, resp.PGNResult)
display.Println(display.Cyan, "Replay it: replay %s", resp.GameID)
return nil
}
func playerLabel(p api.PlayerInfo) string { func playerLabel(p api.PlayerInfo) string {
switch { switch {
case p.Type == 2: case p.Type == 2:
+26
View File
@@ -0,0 +1,26 @@
//go:build !js && !wasm
package command
import (
"fmt"
"os"
)
const maxImportFile = 64 << 10
// readImportFile reads a PGN file for import.
func readImportFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", err
}
if info.Size() > maxImportFile {
return "", fmt.Errorf("%s is larger than %d bytes", path, maxImportFile)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build js && wasm
package command
import "fmt"
// readImportFile has no file system in the browser; the web board imports
// a pasted PGN instead.
func readImportFile(path string) (string, error) {
return "", fmt.Errorf("files cannot be read in the browser; paste the PGN into the web board's Import instead")
}
+1
View File
@@ -152,6 +152,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
{"draw", "", ""}, {"draw", "", ""},
{"games", "g", ""}, {"games", "g", ""},
{"pgn", "", ""}, {"pgn", "", ""},
{"import", "", ""},
} }
replayCommands := []cmdInfo{ replayCommands := []cmdInfo{
+312
View File
@@ -0,0 +1,312 @@
package chess
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// Limits on imported PGN. One game of ordinary length is a few kilobytes;
// the longest games on record are under 300 moves.
const (
MaxPGNSize = 64 << 10
MaxPGNPlies = 1000
maxPGNTags = 64
maxPGNTagName = 32
maxPGNTagValue = 255
maxPGNTokenLength = 16
maxVariationDepth = 16
)
// ErrInvalidPGN reports text that is not one well-formed PGN game.
var ErrInvalidPGN = errors.New("invalid PGN")
// ParsedPGN is one game read from PGN import format: its tag pairs in order
// and the main line's SAN moves. Comments, annotations, numeric annotation
// glyphs and variations are read and dropped.
type ParsedPGN struct {
Tags []Tag
SAN []string
Result string // "1-0", "0-1", "1/2-1/2", or "*"
}
// Tag returns the value of the named tag, or "" when absent.
func (g *ParsedPGN) Tag(name string) string {
for _, t := range g.Tags {
if t.Name == name {
return t.Value
}
}
return ""
}
var (
pgnTagName = regexp.MustCompile(`^[A-Za-z0-9_]{1,32}$`)
// A SAN token: castling, or piece, disambiguation, capture, destination,
// promotion, with check and annotation suffixes.
pgnSAN = regexp.MustCompile(`^(?:O-O(?:-O)?|0-0(?:-0)?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=?[QRBN])?)[+#]?[!?]{0,2}$`)
)
func pgnError(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidPGN, fmt.Sprintf(format, args...))
}
// ParsePGN reads exactly one game. It is strict about form: well-formed tag
// pairs, printable text only, balanced comments and variations, and SAN
// tokens of the standard shape; anything else is refused rather than
// guessed at. Move legality is not checked here (see Replay and ParseSAN).
func ParsePGN(text string) (*ParsedPGN, error) {
if len(text) > MaxPGNSize {
return nil, pgnError("larger than %d bytes", MaxPGNSize)
}
text = strings.TrimPrefix(text, "\uFEFF")
if !utf8.ValidString(text) {
return nil, pgnError("not valid UTF-8")
}
for i, r := range text {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if unicode.IsControl(r) || !unicode.IsPrint(r) && !unicode.IsSpace(r) {
return nil, pgnError("control character at byte %d", i)
}
}
text = strings.ReplaceAll(text, "\r\n", "\n")
game := &ParsedPGN{}
rest, err := parsePGNTags(text, game)
if err != nil {
return nil, err
}
if err := parsePGNMovetext(rest, game); err != nil {
return nil, err
}
if len(game.SAN) == 0 {
return nil, pgnError("no moves")
}
if tag := game.Tag("Result"); tag != "" {
switch tag {
case "1-0", "0-1", "1/2-1/2", "*":
default:
return nil, pgnError("Result tag %q is not a result", tag)
}
if game.Result == "" {
game.Result = tag
} else if game.Result != tag {
return nil, pgnError("Result tag %q disagrees with the movetext result %q", tag, game.Result)
}
}
if game.Result == "" {
game.Result = "*"
}
return game, nil
}
// parsePGNTags reads the tag section and returns the movetext that follows.
func parsePGNTags(text string, game *ParsedPGN) (string, error) {
seen := make(map[string]bool)
for {
trimmed := strings.TrimLeft(text, " \t\n")
if !strings.HasPrefix(trimmed, "[") {
return text, nil
}
end := strings.IndexByte(trimmed, '\n')
if end < 0 {
end = len(trimmed)
}
line := trimmed[:end]
tag, err := parsePGNTag(line)
if err != nil {
return "", err
}
if seen[tag.Name] {
return "", pgnError("tag %s given twice", tag.Name)
}
if len(game.Tags) >= maxPGNTags {
return "", pgnError("more than %d tags", maxPGNTags)
}
seen[tag.Name] = true
game.Tags = append(game.Tags, tag)
text = trimmed[end:]
}
}
// parsePGNTag reads one `[Name "Value"]` line.
func parsePGNTag(line string) (Tag, error) {
body := strings.TrimSpace(line)
if !strings.HasPrefix(body, "[") || !strings.HasSuffix(body, "]") {
return Tag{}, pgnError("malformed tag line %q", line)
}
body = strings.TrimSpace(body[1 : len(body)-1])
space := strings.IndexAny(body, " \t")
if space < 0 {
return Tag{}, pgnError("malformed tag line %q", line)
}
name := body[:space]
if !pgnTagName.MatchString(name) {
return Tag{}, pgnError("invalid tag name %q", name)
}
quoted := strings.TrimSpace(body[space:])
if len(quoted) < 2 || quoted[0] != '"' || quoted[len(quoted)-1] != '"' {
return Tag{}, pgnError("tag %s value is not quoted", name)
}
var value strings.Builder
escaped := false
inner := quoted[1 : len(quoted)-1]
for i := 0; i < len(inner); i++ {
c := inner[i]
switch {
case escaped:
if c != '\\' && c != '"' {
return Tag{}, pgnError("tag %s has an invalid escape", name)
}
value.WriteByte(c)
escaped = false
case c == '\\':
escaped = true
case c == '"':
return Tag{}, pgnError("tag %s value has an unescaped quote", name)
default:
value.WriteByte(c)
}
}
if escaped {
return Tag{}, pgnError("tag %s value ends in a backslash", name)
}
if utf8.RuneCountInString(value.String()) > maxPGNTagValue {
return Tag{}, pgnError("tag %s value longer than %d characters", name, maxPGNTagValue)
}
return Tag{Name: name, Value: value.String()}, nil
}
// parsePGNMovetext tokenizes the movetext: moves of the main line are kept,
// comments, variations, annotations and move numbers are read past, and
// the game ends at a result token.
func parsePGNMovetext(text string, game *ParsedPGN) error {
depth := 0
lineStart := true
i := 0
for i < len(text) {
c := text[i]
switch {
case c == '\n':
lineStart = true
i++
continue
case c == ' ' || c == '\t':
i++
continue
case c == '%' && lineStart:
// Escape mechanism: the rest of the line is for other software.
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return nil
}
i += end
continue
}
lineStart = false
if game.Result != "" {
return pgnError("text after the result (one game per import)")
}
switch c {
case '{':
end := strings.IndexByte(text[i:], '}')
if end < 0 {
return pgnError("unterminated comment")
}
i += end + 1
case '}':
return pgnError("unexpected }")
case ';':
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return nil
}
i += end
case '(':
depth++
if depth > maxVariationDepth {
return pgnError("variations nested deeper than %d", maxVariationDepth)
}
i++
case ')':
depth--
if depth < 0 {
return pgnError("unexpected )")
}
i++
case '$':
j := i + 1
for j < len(text) && j-i <= 3 && text[j] >= '0' && text[j] <= '9' {
j++
}
if j == i+1 {
return pgnError("malformed numeric annotation glyph")
}
i = j
case '*':
if depth == 0 {
game.Result = "*"
}
i++
default:
// A token of visible, non-delimiter characters.
j := i
for j < len(text) && !strings.ContainsRune(" \t\n{}();$", rune(text[j])) {
j++
}
token := text[i:j]
if len(token) > maxPGNTokenLength {
return pgnError("token %q too long", token[:maxPGNTokenLength]+"…")
}
i = j
if err := pgnToken(token, depth, game); err != nil {
return err
}
}
}
if depth != 0 {
return pgnError("unterminated variation")
}
return nil
}
// pgnToken classifies one movetext token: a result, a move number, or a SAN
// move (kept when on the main line).
func pgnToken(token string, depth int, game *ParsedPGN) error {
switch token {
case "1-0", "0-1", "1/2-1/2":
if depth == 0 {
game.Result = token
}
return nil
}
if token[0] >= '0' && token[0] <= '9' && !strings.HasPrefix(token, "0-0") {
// A move number: digits and dots ("12." or "12..."), possibly glued
// to the move that follows ("12.Nf3").
digits := strings.TrimLeft(token, "0123456789")
number := token[:len(token)-len(digits)]
if len(number) > 4 || !strings.HasPrefix(digits, ".") {
return pgnError("unexpected token %q", token)
}
if rest := strings.TrimLeft(digits, "."); rest != "" {
return pgnToken(rest, depth, game)
}
return nil
}
if !pgnSAN.MatchString(token) {
return pgnError("unexpected token %q", token)
}
if depth > 0 {
return nil
}
if len(game.SAN) >= MaxPGNPlies {
return pgnError("more than %d plies", MaxPGNPlies)
}
game.SAN = append(game.SAN, strings.TrimRight(token, "!?"))
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package chess
import (
"errors"
"strings"
"testing"
)
const scholarsMate = `[Event "Casual game"]
[Site "?"]
[Date "2026.01.02"]
[Round "-"]
[White "alice"]
[Black "bob"]
[Result "1-0"]
[Note "with \"quotes\" and a \\ backslash"]
1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? {a blunder} ( 3... g6 $1 4. Qf3 ) 4. Qxf7# 1-0
`
func TestParsePGN(t *testing.T) {
game, err := ParsePGN(scholarsMate)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(game.SAN, " "); got != "e4 e5 Bc4 Nc6 Qh5 Nf6 Qxf7#" {
t.Errorf("SAN = %q", got)
}
if game.Result != "1-0" || game.Tag("White") != "alice" || game.Tag("Round") != "-" {
t.Errorf("result %q, tags %v", game.Result, game.Tags)
}
if game.Tag("Note") != `with "quotes" and a \ backslash` {
t.Errorf("escaped value = %q", game.Tag("Note"))
}
if len(game.Tags) != 8 {
t.Errorf("%d tags", len(game.Tags))
}
// Legal in the rules core, and the final position is mate.
pos, _ := ParseFEN(StartFEN)
for i, san := range game.SAN {
m, err := pos.ParseSAN(san)
if err != nil {
t.Fatalf("ply %d %q: %v", i+1, san, err)
}
pos = pos.Play(m)
}
if !pos.InCheck() || pos.HasLegalMoves() {
t.Error("final position is not mate")
}
}
func TestParsePGNAcceptsLooseButValidForms(t *testing.T) {
for name, text := range map[string]string{
"no tags, no result": "1.e4 e5 2.Nf3",
"semicolon comment": "1. e4 ; best by test\ne5 *",
"black first": "[FEN \"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1\"]\n[SetUp \"1\"]\n\n1... e5 2. Nf3",
"castling zeros": "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 Nf6 5. 0-0-0",
"escape line": "%ignored line\n1. e4 e5 1/2-1/2",
"nag and annotations": "1. e4!! $14 e5?! 2. Nf3+ ",
"windows line endings": "[Event \"x\"]\r\n\r\n1. e4 e5 0-1\r\n",
"byte order mark": "\uFEFF1. d4 *",
} {
if _, err := ParsePGN(text); err != nil {
t.Errorf("%s: %v", name, err)
}
}
}
func TestParsePGNRefusesWeirdness(t *testing.T) {
for name, text := range map[string]string{
"empty": "",
"no moves": "[Event \"x\"]\n\n*",
"two games": "1. e4 e5 1-0\n\n1. d4 d5 *",
"text after result": "1. e4 e5 * and then",
"unterminated comment": "1. e4 {oops e5",
"unterminated variation": "1. e4 (1. d4 e5",
"stray paren": "1. e4 ) e5",
"stray brace": "1. e4 } e5",
"bad token": "1. e4 e5 2. Nf3 hello",
"bad move number": "1x. e4",
"long token": "1. " + strings.Repeat("e", 40),
"html": "1. e4 <script>alert(1)</script>",
"bad tag name": "[Ev ent \"x\"]\n\n1. e4 *",
"unquoted tag": "[Event x]\n\n1. e4 *",
"unescaped quote": "[Event \"a\"b\"]\n\n1. e4 *",
"bad escape": "[Event \"a\\n\"]\n\n1. e4 *",
"duplicate tag": "[Event \"x\"]\n[Event \"y\"]\n\n1. e4 *",
"result mismatch": "[Result \"1-0\"]\n\n1. e4 e5 0-1",
"bad result tag": "[Result \"won\"]\n\n1. e4 e5",
"control character": "1. e4\x07 e5",
"invalid utf8": "1. e4 \xff e5",
"too many plies": strings.Repeat("Nf3 Nf6 Ng1 Ng8 ", MaxPGNPlies/4+1),
"deep variations": strings.Repeat("(", maxVariationDepth+1) + "1. e4" + strings.Repeat(")", maxVariationDepth+1),
"long tag value": "[Event \"" + strings.Repeat("x", maxPGNTagValue+1) + "\"]\n\n1. e4 *",
"nag without digits": "1. e4 $ e5",
"trailing garbage in tag": "[Event \"x\"] junk\n\n1. e4 *",
} {
_, err := ParsePGN(text)
if !errors.Is(err, ErrInvalidPGN) {
t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err)
}
}
if _, err := ParsePGN(strings.Repeat("1. e4 e5 ", MaxPGNSize)); !errors.Is(err, ErrInvalidPGN) {
t.Errorf("oversized: %v", err)
}
}
// Exported games read back unchanged.
func TestParsePGNRoundTripsExport(t *testing.T) {
line, err := Replay(StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5", "a7a6", "b5a4", "g8f6", "e1g1"})
if err != nil {
t.Fatal(err)
}
text := PGN{
Tags: []Tag{{Name: "Event", Value: "Round trip \"test\""}, {Name: "White", Value: "a"}, {Name: "Black", Value: "b"}},
StartFEN: StartFEN, SAN: line.SAN, Comment: "Still going.", Result: "*",
}.String()
game, err := ParsePGN(text)
if err != nil {
t.Fatalf("%v\n%s", err, text)
}
if strings.Join(game.SAN, " ") != strings.Join(line.SAN, " ") || game.Tag("Event") != `Round trip "test"` || game.Result != "*" {
t.Errorf("round trip: %v %q", game.SAN, game.Tag("Event"))
}
}
func FuzzParsePGN(f *testing.F) {
f.Add(scholarsMate)
f.Add("1. e4 e5 (1... c5) {x} $1 2. Nf3 *")
f.Add("[A \"b\"]\n\n")
f.Fuzz(func(t *testing.T, text string) {
game, err := ParsePGN(text)
if err != nil {
if !errors.Is(err, ErrInvalidPGN) {
t.Fatalf("error %v is not ErrInvalidPGN", err)
}
return
}
if len(game.SAN) == 0 || len(game.SAN) > MaxPGNPlies {
t.Fatalf("accepted %d plies", len(game.SAN))
}
for _, san := range game.SAN {
if !pgnSAN.MatchString(san) {
t.Fatalf("accepted token %q", san)
}
}
})
}
+30 -2
View File
@@ -82,9 +82,30 @@ type BoardResponse struct {
Board string `json:"board"` // ASCII representation Board string `json:"board"` // ASCII representation
} }
// ImportGameRequest is a PGN game to store for the signed-in user.
type ImportGameRequest struct {
PGN string `json:"pgn" validate:"required,max=65536"`
}
// ImportGameResponse names the stored game.
type ImportGameResponse struct {
GameID string `json:"gameId"`
MoveCount int `json:"moveCount"`
Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
}
// RemoveGameResponse says what DELETE /users/me/games/{id} did: "game" (the
// game is gone) or "claim" (another player keeps it; the caller's side was
// released).
type RemoveGameResponse struct {
Removed string `json:"removed"`
}
// GameHistoryResponse is the durable replay representation of a game. Moves // GameHistoryResponse is the durable replay representation of a game. Moves
// are ordered and include the resulting FEN so clients do not need an engine // are ordered and include the resulting FEN so clients do not need an engine
// to replay a stored game. // to replay a stored game. Origin is "played" or "imported"; Event, Site and
// Round are set for imported games that carried them.
// //
// PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and // PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and
// Termination names how a result was reached (see core.Termination; omitted // Termination names how a result was reached (see core.Termination; omitted
@@ -99,6 +120,10 @@ type GameHistoryResponse struct {
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"` Concession *Concession `json:"concession,omitempty"`
Origin string `json:"origin"`
Event string `json:"event,omitempty"`
Site string `json:"site,omitempty"`
Round string `json:"round,omitempty"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"` Moves []HistoryMove `json:"moves"`
} }
@@ -117,7 +142,8 @@ 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. // Termination says how a finished game ended, as in the history; Origin is
// "played" or "imported", and Event is the imported game's event tag.
type GameSummary struct { type GameSummary struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
@@ -128,6 +154,8 @@ type GameSummary struct {
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"`
Origin string `json:"origin"`
Event string `json:"event,omitempty"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
} }
+86
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core" "github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/processor" "github.com/lixenwraith/chess/internal/server/processor"
"github.com/lixenwraith/chess/internal/server/service" "github.com/lixenwraith/chess/internal/server/service"
@@ -229,6 +230,20 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw) api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
api.Get("/games/:gameId/board", h.GetBoard) api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames) api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
api.Delete("/users/me/games/:gameId", AuthRequired(validateToken), h.RemoveGame)
// Importing replays a whole game through the rules core: 5 a minute per IP.
api.Post("/games/import", AuthRequired(validateToken), limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "5 imports per minute allowed",
})
},
}), h.ImportGame)
return app return app
} }
@@ -816,6 +831,77 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
return c.JSON(games) return c.JSON(games)
} }
// ImportGame stores a PGN game for the signed-in user and names it.
func (h *HTTPHandler) ImportGame(c *fiber.Ctx) error {
userID, _ := c.Locals("userID").(string)
if userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized", Code: core.ErrUnauthorized,
})
}
body := c.Locals("validatedBody")
if validated, _ := c.Locals("validated").(bool); !validated || body == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected", Code: core.ErrInternalError,
})
}
req := *(body.(*core.ImportGameRequest))
imported, err := h.svc.ImportGame(userID, req.PGN)
if err != nil {
switch {
case errors.Is(err, service.ErrInvalidPGN):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid PGN", Code: core.ErrInvalidRequest,
Details: strings.TrimPrefix(err.Error(), "invalid PGN: "),
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to import game", Code: core.ErrInternalError,
})
}
return c.Status(fiber.StatusCreated).JSON(core.ImportGameResponse{
GameID: imported.GameID, MoveCount: imported.Plies, Result: imported.Result,
PGNResult: chess.ResultToken(imported.Result),
})
}
// RemoveGame takes a stored game off the signed-in user's list: deleted when
// the user alone holds it, released when another player keeps it.
func (h *HTTPHandler) RemoveGame(c *fiber.Ctx) error {
userID, _ := c.Locals("userID").(string)
if userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized", Code: core.ErrUnauthorized,
})
}
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return invalidGameID(c)
}
outcome, err := h.svc.RemoveGame(gameID, userID)
if err != nil {
switch {
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "no such game of yours", Code: core.ErrGameNotFound,
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to remove game", Code: core.ErrInternalError,
})
}
return c.JSON(core.RemoveGameResponse{Removed: string(outcome)})
}
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) { func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
raw := c.Query(name) raw := c.Query(name)
if raw == "" { if raw == "" {
+2
View File
@@ -99,6 +99,8 @@ func TestAPIRoutes(t *testing.T) {
"POST /api/games/:gameId/resign", "POST /api/games/:gameId/resign",
"POST /api/games/:gameId/draw", "POST /api/games/:gameId/draw",
"GET /api/users/me/games", "GET /api/users/me/games",
"DELETE /api/users/me/games/:gameId",
"POST /api/games/import",
} { } {
if !registered[want] { if !registered[want] {
t.Errorf("route %s is not registered", want) t.Errorf("route %s is not registered", want)
+2
View File
@@ -28,6 +28,8 @@ func validationMiddleware(c *fiber.Ctx) error {
var requestType any var requestType any
switch { switch {
case strings.HasSuffix(path, "/games/import") && method == fiber.MethodPost:
requestType = &core.ImportGameRequest{}
case strings.HasSuffix(path, "/games") && method == fiber.MethodPost: case strings.HasSuffix(path, "/games") && method == fiber.MethodPost:
requestType = &core.CreateGameRequest{} requestType = &core.CreateGameRequest{}
case strings.HasSuffix(path, "/players") && method == fiber.MethodPut: case strings.HasSuffix(path, "/players") && method == fiber.MethodPut:
+13 -3
View File
@@ -52,8 +52,9 @@ type PGN struct {
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the // BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
// first ply moves, with result "*" and no termination when that stops short // first ply moves, with result "*" and no termination when that stops short
// of the end of the line. // of the end of the line. site is the Site tag of a game played on this
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) { // server (PGN's "?" when empty); an imported game keeps its own.
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int, site string) (*PGN, error) {
if ply > len(moves) { if ply > len(moves) {
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves)) return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
} }
@@ -75,9 +76,18 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
} }
} }
start := record.StartTimeUTC.UTC() start := record.StartTimeUTC.UTC()
event, round := "Casual game", ""
if record.ImportedBy != "" {
event, site, round = record.Event, record.Site, record.Round
if event == "" {
event = "Imported game"
}
}
tags := []chess.Tag{ tags := []chess.Tag{
{Name: "Event", Value: "Casual game"}, {Name: "Event", Value: event},
{Name: "Site", Value: site},
{Name: "Date", Value: start.Format("2006.01.02")}, {Name: "Date", Value: start.Format("2006.01.02")},
{Name: "Round", Value: round},
{Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)}, {Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)},
{Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)}, {Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)},
{Name: "GameId", Value: record.GameID}, {Name: "GameId", Value: record.GameID},
+25 -5
View File
@@ -129,7 +129,7 @@ func TestBuildPGN(t *testing.T) {
rec := record(chess.StartFEN, "white_wins", "resignation") rec := record(chess.StartFEN, "white_wins", "resignation")
rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice" rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice"
pgn, err := BuildPGN(rec, moves, -1) pgn, err := BuildPGN(rec, moves, -1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -143,25 +143,45 @@ func TestBuildPGN(t *testing.T) {
t.Errorf("filename %q", pgn.Filename) t.Errorf("filename %q", pgn.Filename)
} }
partial, err := BuildPGN(rec, moves, 1) partial, err := BuildPGN(rec, moves, 1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" { if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" {
t.Errorf("partial %q:\n%s", partial.Filename, partial.Text) t.Errorf("partial %q:\n%s", partial.Filename, partial.Text)
} }
if _, err := BuildPGN(rec, moves, 3); err == nil { if _, err := BuildPGN(rec, moves, 3, ""); err == nil {
t.Error("ply beyond the line accepted") t.Error("ply beyond the line accepted")
} }
} }
// A game played here names the server's site; an imported game keeps its own.
func TestBuildPGNSite(t *testing.T) {
moves := stored(t, chess.StartFEN, "e2e4")
rec := record(chess.StartFEN, "", "")
for _, tc := range []struct{ importedBy, recordSite, site, want string }{
{"", "", "lixen.com", "lixen.com"},
{"", "", "", "?"},
{"alice", "Linares ESP", "lixen.com", "Linares ESP"},
} {
rec.ImportedBy, rec.Site = tc.importedBy, tc.recordSite
pgn, err := BuildPGN(rec, moves, -1, tc.site)
if err != nil {
t.Fatal(err)
}
if want := "[Site \"" + tc.want + "\"]\n"; !strings.Contains(pgn.Text, want) {
t.Errorf("site %q, imported by %q: PGN lacks %q:\n%s", tc.site, tc.importedBy, want, pgn.Text)
}
}
}
// A resignation taken back by an undo is noted before the live result. // A resignation taken back by an undo is noted before the live result.
func TestBuildPGNNotesContinuedConcession(t *testing.T) { func TestBuildPGNNotesContinuedConcession(t *testing.T) {
moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4") moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4")
rec := record(chess.StartFEN, "", "") rec := record(chess.StartFEN, "", "")
rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2 rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2
pgn, err := BuildPGN(rec, moves, -1) pgn, err := BuildPGN(rec, moves, -1, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -171,7 +191,7 @@ func TestBuildPGNNotesContinuedConcession(t *testing.T) {
// The concession that is still the result needs no note. // The concession that is still the result needs no note.
rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3 rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3
if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") { if pgn, _ = BuildPGN(rec, moves, -1, ""); strings.Contains(pgn.Text, "continued") {
t.Errorf("standing resignation noted as continued:\n%s", pgn.Text) t.Errorf("standing resignation noted as continued:\n%s", pgn.Text)
} }
} }
+7 -1
View File
@@ -43,6 +43,10 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
Termination: record.Termination, Termination: record.Termination,
StartTimeUTC: record.StartTimeUTC, StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC, EndTimeUTC: record.EndTimeUTC,
Origin: record.Origin(),
Event: record.Event,
Site: record.Site,
Round: record.Round,
Players: playersResponse(*record), Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)), Moves: make([]core.HistoryMove, 0, len(moves)),
} }
@@ -75,7 +79,7 @@ func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
pgn, err := replay.BuildPGN(record, moves, ply) pgn, err := replay.BuildPGN(record, moves, ply, s.site)
if errors.Is(err, replay.ErrNotation) { if errors.Is(err, replay.ErrNotation) {
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err) slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
} }
@@ -177,6 +181,8 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game
EndTimeUTC: record.EndTimeUTC, EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount, MoveCount: record.MoveCount,
FinalFEN: record.FinalFEN, FinalFEN: record.FinalFEN,
Origin: record.Origin(),
Event: record.Event,
Players: playersResponse(record.GameRecord), Players: playersResponse(record.GameRecord),
}) })
} }
+250
View File
@@ -0,0 +1,250 @@
package service
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
"github.com/google/uuid"
)
// ErrInvalidPGN wraps chess.ErrInvalidPGN and move-legality failures of an
// import; the message says what was wrong.
var ErrInvalidPGN = chess.ErrInvalidPGN
// Imported is what ImportGame reports.
type Imported struct {
GameID string
Plies int
Result string // stored result, "" while unfinished
}
// ImportGame stores a game read from PGN for userID: tags give the names,
// event, site, round and date; every move is replayed by the rules core and
// refused if illegal; the result comes from the PGN's result token, its
// termination from the final position (mate, stalemate, a dead position,
// repetition or the fifty-move rule) or else resignation or agreement.
func (s *Service) ImportGame(userID, text string) (*Imported, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
if uuid.Validate(userID) != nil {
return nil, errors.New("an import needs a signed-in user")
}
game, err := chess.ParsePGN(text)
if err != nil {
return nil, err
}
startFEN := chess.StartFEN
if fen := game.Tag("FEN"); fen != "" || game.Tag("SetUp") == "1" {
if fen == "" {
return nil, fmt.Errorf("%w: SetUp without a FEN tag", ErrInvalidPGN)
}
normalized, err := chess.NormalizeFEN(fen)
if err != nil {
return nil, fmt.Errorf("%w: FEN tag: %v", ErrInvalidPGN, err)
}
startFEN = normalized
}
// Replay the moves, keeping the position after each ply.
pos, err := chess.ParseFEN(startFEN)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPGN, err)
}
gameID := uuid.NewString()
start := importStartTime(game)
moves := make([]storage.MoveRecord, 0, len(game.SAN))
positions := []*chess.Position{pos}
for i, san := range game.SAN {
m, err := pos.ParseSAN(san)
if err != nil {
return nil, fmt.Errorf("%w: ply %d (%s): %v", ErrInvalidPGN, i+1, san, err)
}
color := "w"
if pos.Turn() == chess.Black {
color = "b"
}
pos = pos.Play(m)
positions = append(positions, pos)
moves = append(moves, storage.MoveRecord{
GameID: gameID, MoveNumber: i + 1, MoveUCI: m.UCI(), FENAfterMove: pos.FEN(),
PlayerColor: color, MoveTimeUTC: start,
})
}
result, termination, err := importOutcome(game.Result, positions)
if err != nil {
return nil, err
}
record := storage.GameRecord{
GameID: gameID,
InitialFEN: startFEN,
WhitePlayerID: uuid.NewString(),
WhiteType: int(core.PlayerHuman),
WhiteName: importName(game.Tag("White")),
BlackPlayerID: uuid.NewString(),
BlackType: int(core.PlayerHuman),
BlackName: importName(game.Tag("Black")),
Result: result,
Termination: termination,
StartTimeUTC: start,
ImportedBy: userID,
Event: importTag(game.Tag("Event"), 200),
Site: importTag(game.Tag("Site"), 200),
Round: importTag(game.Tag("Round"), 40),
}
if result != "" {
end := start
record.EndTimeUTC = &end
}
if err := s.store.ImportGame(record, moves); err != nil {
return nil, fmt.Errorf("%w: import game: %v", ErrStorageUnavailable, err)
}
slog.Info("game imported", "game_id", gameID, "user_id", userID, "plies", len(moves), "result", result)
return &Imported{GameID: gameID, Plies: len(moves), Result: result}, nil
}
// importOutcome maps the PGN result to the stored result and a termination
// the final position supports.
func importOutcome(token string, positions []*chess.Position) (result, termination string, err error) {
final := positions[len(positions)-1]
mate := final.InCheck() && !final.HasLegalMoves()
stalemate := !final.InCheck() && !final.HasLegalMoves()
switch token {
case "1-0", "0-1":
result = "white_wins"
if token == "0-1" {
result = "black_wins"
}
// A decisive result against the side to move at mate; otherwise the
// loser resigned (or lost on time, which the schema has no word for).
if mate && ((token == "1-0") == (final.Turn() == chess.Black)) {
return result, string(core.TermCheckmate), nil
}
if mate || stalemate {
return "", "", fmt.Errorf("%w: result %s does not match the final position", ErrInvalidPGN, token)
}
return result, string(core.TermResignation), nil
case "1/2-1/2":
switch {
case mate:
return "", "", fmt.Errorf("%w: result 1/2-1/2 but the final position is checkmate", ErrInvalidPGN)
case stalemate:
return "stalemate", string(core.TermStalemate), nil
case final.InsufficientMaterial():
return "draw", string(core.TermInsufficientMaterial), nil
case chess.Repetitions(positions) >= 3:
return "draw", string(core.TermThreefoldRepetition), nil
case final.Halfmove() >= 100:
return "draw", string(core.TermFiftyMoveRule), nil
}
return "draw", string(core.TermAgreement), nil
}
if mate || stalemate {
return "", "", fmt.Errorf("%w: the game is over but the result is *", ErrInvalidPGN)
}
return "", "", nil
}
// importName is a player name from a tag: "?" and empty mean unknown.
func importName(value string) string {
value = strings.TrimSpace(value)
if value == "?" || value == "" {
return ""
}
if utf8.RuneCountInString(value) > 64 {
value = string([]rune(value)[:64])
}
return value
}
func importTag(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "?" || value == "-" && limit == 40 || value == "" {
return ""
}
if utf8.RuneCountInString(value) > limit {
value = string([]rune(value)[:limit])
}
return value
}
// importStartTime reads UTCDate/UTCTime, else Date, else now. Unknown parts
// ("????.??.??") fall back to the current time.
func importStartTime(game *chess.ParsedPGN) time.Time {
now := time.Now().UTC()
date := game.Tag("UTCDate")
if date == "" {
date = game.Tag("Date")
}
parts := strings.Split(date, ".")
if len(parts) != 3 {
return now
}
year, err1 := strconv.Atoi(parts[0])
month, err2 := strconv.Atoi(parts[1])
day, err3 := strconv.Atoi(parts[2])
if err1 != nil || year < 1400 || year > now.Year()+1 {
return now
}
if err2 != nil || month < 1 || month > 12 {
month = 1
}
if err3 != nil || day < 1 || day > 31 {
day = 1
}
hour, minute, second := 0, 0, 0
if clock := game.Tag("UTCTime"); clock != "" {
if t, err := time.Parse("15:04:05", clock); err == nil {
hour, minute, second = t.Clock()
}
}
t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.UTC)
if t.After(now.Add(24 * time.Hour)) {
return now
}
return t
}
// RemoveGame removes a stored game from the user's list: the game is deleted
// when the user alone holds it, or the user's claim is released when another
// user claims a side (see storage.RemoveGameForUser). A deleted game is also
// unloaded from memory; a released one keeps playing for the other side.
func (s *Service) RemoveGame(gameID, userID string) (storage.RemovalOutcome, error) {
if s.store == nil {
return "", ErrStorageDisabled
}
outcome, err := s.store.RemoveGameForUser(gameID, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
return "", fmt.Errorf("%w: remove game: %v", ErrStorageUnavailable, err)
}
s.mu.Lock()
if g, ok := s.games[gameID]; ok {
switch outcome {
case storage.RemovedGame:
delete(s.games, gameID)
case storage.RemovedClaim:
g.ReleaseUser(userID)
}
}
s.mu.Unlock()
if outcome == storage.RemovedGame {
s.waiter.RemoveGame(gameID)
}
slog.Info("game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome)
return outcome, nil
}
+179
View File
@@ -0,0 +1,179 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
)
const importPGN = `[Event "World Championship"]
[Site "Reykjavik"]
[Date "1972.07.11"]
[Round "1"]
[White "Spassky, Boris"]
[Black "Fischer, Robert"]
[Result "1-0"]
1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0
`
func TestImportGameStoresReplaysAndLists(t *testing.T) {
svc := newPersistentTestService(t)
user, _, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
imported, err := svc.ImportGame(user.UserID, importPGN)
if err != nil {
t.Fatal(err)
}
if imported.Plies != 6 || imported.Result != "white_wins" {
t.Fatalf("imported = %+v", imported)
}
history, err := svc.GetGameHistory(imported.GameID)
if err != nil {
t.Fatal(err)
}
if history.Origin != "imported" || history.Event != "World Championship" || history.Site != "Reykjavik" ||
history.Round != "1" || history.Players.White.Name != "Spassky, Boris" || history.Players.Black.Name != "Fischer, Robert" ||
history.Termination != "resignation" || history.PGNResult != "1-0" ||
history.StartTimeUTC.Format("2006-01-02") != "1972-07-11" {
t.Fatalf("history = %+v", history)
}
var san []string
for _, m := range history.Moves {
san = append(san, m.SAN)
}
if strings.Join(san, " ") != "d4 Nf6 c4 e6 Nf3 d5" {
t.Errorf("SAN = %v", san)
}
pgn, err := svc.GetGamePGN(imported.GameID, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`[Event "World Championship"]`, `[Site "Reykjavik"]`, `[Round "1"]`, `[White "Spassky, Boris"]`, "1. d4 Nf6 2. c4 e6 3. Nf3 d5 { White wins by resignation. } 1-0"} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
page, err := svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != imported.GameID || page.Games[0].Origin != "imported" ||
page.Games[0].Event != "World Championship" || page.Games[0].MoveCount != 6 {
t.Fatalf("listing = %+v", page.Games)
}
// Imported games have no claimed colour.
page, _ = svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10, Color: "white"})
if len(page.Games) != 0 {
t.Errorf("imported game listed under a colour filter")
}
// Outcomes from the final position.
for name, tc := range map[string]struct{ pgn, result, termination string }{
"mate": {"1. f3 e5 2. g4 Qh4# 0-1", "black_wins", "checkmate"},
"stalemate": {"[FEN \"7k/8/6K1/5Q2/8/8/8/8 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Qf7 1/2-1/2", "stalemate", "stalemate"},
"agreed": {"1. e4 e5 1/2-1/2", "draw", "agreement"},
"ongoing": {"1. e4 e5 *", "", ""},
"material": {"[FEN \"4k3/8/8/8/8/8/8/3RK3 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Rd8+ Kxd8 1/2-1/2", "draw", "insufficient_material"},
} {
got, err := svc.ImportGame(user.UserID, tc.pgn)
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
h, _ := svc.GetGameHistory(got.GameID)
if h.Result != tc.result || h.Termination != tc.termination {
t.Errorf("%s: result %q %q, want %q %q", name, h.Result, h.Termination, tc.result, tc.termination)
}
}
// Illegal moves and impossible results are refused.
for name, bad := range map[string]string{
"illegal": "1. e4 e5 2. Ke2 Ke7 3. Kd3 Qh4",
"mate but draw": "1. f3 e5 2. g4 Qh4# 1/2-1/2",
"mate but white": "1. f3 e5 2. g4 Qh4# 1-0",
"over but star": "1. f3 e5 2. g4 Qh4# *",
"bad fen": "[FEN \"nonsense\"]\n[SetUp \"1\"]\n\n1. e4 *",
"not pgn": "hello world",
} {
if _, err := svc.ImportGame(user.UserID, bad); !errors.Is(err, ErrInvalidPGN) {
t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err)
}
}
}
func TestRemoveGameDeletesOwnAndReleasesShared(t *testing.T) {
svc := newPersistentTestService(t)
alice, _, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
bob, _, err := svc.RegisterUser("bob", "", "Password1")
if err != nil {
t.Fatal(err)
}
human := core.PlayerConfig{Type: core.PlayerHuman}
// A game alice alone claims: deleted outright and unloaded.
own := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing)
outcome, err := svc.RemoveGame(own, alice.UserID)
if err != nil || outcome != storage.RemovedGame {
t.Fatalf("own game: %v %v", outcome, err)
}
if _, err := svc.GetGameView(own); err == nil {
t.Error("deleted game still in memory")
}
if _, err := svc.GetGameHistory(own); !errors.Is(err, ErrGameNotFound) {
t.Errorf("deleted game history: %v", err)
}
// An imported game: deleted by its importer, unknown to anyone else.
imported, err := svc.ImportGame(alice.UserID, "1. e4 e5 *")
if err != nil {
t.Fatal(err)
}
if _, err := svc.RemoveGame(imported.GameID, bob.UserID); !errors.Is(err, ErrGameNotFound) {
t.Errorf("bob removing alice's import: %v", err)
}
if outcome, err := svc.RemoveGame(imported.GameID, alice.UserID); err != nil || outcome != storage.RemovedGame {
t.Errorf("alice removing her import: %v %v", outcome, err)
}
// A game both claim: alice's removal releases her side; bob keeps it.
shared := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing)
if err := svc.ApplyMoveWithState(shared, MoveCommit{
ExpectedFEN: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", ExpectedState: core.StateOngoing,
ExpectedTurn: core.ColorBlack, ActorUserID: bob.UserID, MoveUCI: "e7e5",
NewFEN: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
outcome, err = svc.RemoveGame(shared, alice.UserID)
if err != nil || outcome != storage.RemovedClaim {
t.Fatalf("shared game: %v %v", outcome, err)
}
view, err := svc.GetGameView(shared)
if err != nil || view.WhitePlayer.ClaimedBy != "" || view.BlackPlayer.ClaimedBy != bob.UserID {
t.Errorf("shared game after release: %v %+v", err, view.WhitePlayer)
}
history, _ := svc.GetGameHistory(shared)
if history.Players.White.ClaimedBy != "" || history.Players.White.Name != "" || history.Players.Black.Name != "bob" {
t.Errorf("stored claims after release: %+v %+v", history.Players.White, history.Players.Black)
}
if page, _ := svc.GetUserGames(alice.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 0 {
t.Errorf("alice still lists %d games", len(page.Games))
}
if page, _ := svc.GetUserGames(bob.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 1 {
t.Errorf("bob lists %d games", len(page.Games))
}
if _, err := svc.RemoveGame(shared, alice.UserID); !errors.Is(err, ErrGameNotFound) {
t.Errorf("second removal: %v", err)
}
}
+7
View File
@@ -43,6 +43,7 @@ type Service struct {
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
mail *mailer // password recovery; nil when no transport is configured mail *mailer // password recovery; nil when no transport is configured
site string // Site tag of exported games played here; empty is PGN's "?"
} }
// New creates a service with optional storage. jwtSecret signs session tokens // New creates a service with optional storage. jwtSecret signs session tokens
@@ -93,6 +94,12 @@ func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
s.mu.Unlock() s.mu.Unlock()
} }
// SetSite names this server in the Site tag of the games it exports, from
// the web client's public address; call it before serving.
func (s *Service) SetSite(site string) {
s.site = site
}
// SetMaxUsers caps the number of accounts public registration may create; // SetMaxUsers caps the number of accounts public registration may create;
// zero removes the cap. Accounts created through the CLI are not limited. // zero removes the cap. Accounts created through the CLI are not limited.
func (s *Service) SetMaxUsers(limit int) { func (s *Service) SetMaxUsers(limit int) {
+11 -4
View File
@@ -17,7 +17,8 @@ const gameSelectColumns = `
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
g.result, g.termination, g.start_time_utc, g.end_time_utc, g.result, g.termination, g.start_time_utc, g.end_time_utc,
g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc` g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc,
g.imported_by, g.event, g.site, g.round`
// claimantName snapshots a claimant's username inside the writing // claimantName snapshots a claimant's username inside the writing
// transaction; NULL when the slot is unclaimed or the account is gone. // transaction; NULL when the slot is unclaimed or the account is gone.
@@ -265,7 +266,7 @@ func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error {
} }
return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error { return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `DELETE FROM games g result, err := tx.ExecContext(ctx, `DELETE FROM games g
WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL AND g.imported_by IS NULL
AND g.start_time_utc < $1 AND g.start_time_utc < $1
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
AND NOT EXISTS ( AND NOT EXISTS (
@@ -302,7 +303,7 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
} }
args = append(args, playerID) args = append(args, playerID)
query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id, query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id,
g.white_claimed_by, g.black_claimed_by)`, len(args)) g.white_claimed_by, g.black_claimed_by, g.imported_by)`, len(args))
} }
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC" query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
@@ -423,7 +424,7 @@ func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummar
var where string var where string
switch q.Color { switch q.Color {
case "": case "":
where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)" where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1 OR g.imported_by = $1)"
case "w": case "w":
where = "g.white_claimed_by = $1" where = "g.white_claimed_by = $1"
case "b": case "b":
@@ -541,6 +542,7 @@ type rowScanner interface {
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
var concessionResult, concessionTermination sql.NullString var concessionResult, concessionTermination sql.NullString
var importedBy, event, site, round sql.NullString
var concessionPly sql.NullInt64 var concessionPly sql.NullInt64
var endTime, concessionTime sql.NullTime var endTime, concessionTime sql.NullTime
dest := []any{ dest := []any{
@@ -551,6 +553,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
&blackClaimed, &blackName, &blackClaimed, &blackName,
&result, &termination, &record.StartTimeUTC, &endTime, &result, &termination, &record.StartTimeUTC, &endTime,
&concessionResult, &concessionTermination, &concessionPly, &concessionTime, &concessionResult, &concessionTermination, &concessionPly, &concessionTime,
&importedBy, &event, &site, &round,
} }
if err := scanner.Scan(append(dest, extra...)...); err != nil { if err := scanner.Scan(append(dest, extra...)...); err != nil {
return err return err
@@ -567,6 +570,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
record.ConcessionTermination = concessionTermination.String record.ConcessionTermination = concessionTermination.String
record.ConcessionPly = int(concessionPly.Int64) record.ConcessionPly = int(concessionPly.Int64)
record.ConcessionTimeUTC = utcPointer(concessionTime) record.ConcessionTimeUTC = utcPointer(concessionTime)
record.ImportedBy = importedBy.String
record.Event = event.String
record.Site = site.String
record.Round = round.String
return nil return nil
} }
+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
)
// ImportGame stores a game read from PGN with its whole move list in one
// transaction, behind every gameplay write already queued, and returns once
// it has committed. The record's ImportedBy names the owner; names, event,
// site and round come from the PGN's tags.
func (s *Store) ImportGame(record GameRecord, moves []MoveRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required")
}
if !validUUID(record.ImportedBy) {
return errors.New("an imported game needs its importer")
}
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err
}
for i, move := range moves {
if move.GameID != record.GameID || move.MoveNumber != i+1 || move.MoveUCI == "" || move.FENAfterMove == "" ||
(move.PlayerColor != "w" && move.PlayerColor != "b") {
return fmt.Errorf("move %d is malformed", i+1)
}
}
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
return s.enqueueWait(ctx, "import_game", func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
start_time_utc, result, termination, end_time_utc,
imported_by, event, site, round
) VALUES ($1, $2, $3, $4, 0, 0, NULL, $5, $6, $7, 0, 0, NULL, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, nullableString(record.WhiteName),
record.BlackPlayerID, record.BlackType, nullableString(record.BlackName),
record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination), record.EndTimeUTC,
record.ImportedBy, nullableString(record.Event), nullableString(record.Site), nullableString(record.Round),
); err != nil {
return err
}
for _, move := range moves {
if _, err := tx.ExecContext(ctx, `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES ($1, $2, $3, $4, $5, $6)`,
move.GameID, move.MoveNumber, move.MoveUCI, move.FENAfterMove, move.PlayerColor, move.MoveTimeUTC,
); err != nil {
return err
}
}
slog.Debug("storage game imported", "game_id", record.GameID, "plies", len(moves), "user_id", record.ImportedBy)
return nil
})
}
// RemovalOutcome says what RemoveGameForUser did.
type RemovalOutcome string
const (
// RemovedGame: the game and its moves are gone.
RemovedGame RemovalOutcome = "game"
// RemovedClaim: another user still claims a side, so only the caller's
// claim and name were cleared; the game stays with the other player.
RemovedClaim RemovalOutcome = "claim"
)
// RemoveGameForUser deletes a stored game the user alone holds (as sole
// claimant or importer), or, when another user claims a side, releases the
// user's own claim so the game leaves their list but not the other's. It
// returns sql.ErrNoRows when the user holds nothing in the game. Ordered
// behind queued writes, it returns once committed.
func (s *Store) RemoveGameForUser(gameID, userID string) (RemovalOutcome, error) {
if !validUUID(gameID) || !validUUID(userID) {
return "", sql.ErrNoRows
}
var outcome RemovalOutcome
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
// A missing row or a game the user holds nothing in is an answer, not a
// failed write: the transaction then changes nothing and outcome stays
// empty, so the writer is never degraded by it.
err := s.enqueueWait(ctx, "remove_game_for_user", func(ctx context.Context, tx *sql.Tx) error {
var white, black, importer sql.NullString
err := tx.QueryRowContext(ctx,
`SELECT white_claimed_by, black_claimed_by, imported_by FROM games WHERE game_id = $1 FOR UPDATE`,
gameID).Scan(&white, &black, &importer)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if white.String != userID && black.String != userID && importer.String != userID {
return nil
}
others := (white.Valid && white.String != userID) || (black.Valid && black.String != userID)
if others {
_, err := tx.ExecContext(ctx, `UPDATE games SET
white_claimed_by = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_name END
WHERE game_id = $1`, gameID, userID)
outcome = RemovedClaim
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = $1`, gameID)
outcome = RemovedGame
return err
})
if err != nil {
return "", err
}
if outcome == "" {
return "", sql.ErrNoRows
}
slog.Debug("storage game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome)
return outcome, nil
}
+6 -5
View File
@@ -62,9 +62,10 @@ func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) {
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) }) return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
} }
// OrphanedGames returns games that registered users claimed but whose // OrphanedGames returns games that registered users claimed or imported but
// claimants were all deleted, idle since cutoff by the anonymous-game rule // whose claimants and importer were all deleted, idle since cutoff by the
// (start, last move, and end all before it), excluding live games. // anonymous-game rule (start, last move, and end all before it), excluding
// live games.
func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) { func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) {
if live == nil { if live == nil {
live = []string{} live = []string{}
@@ -72,10 +73,10 @@ func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error)
ctx, cancel := opContext() ctx, cancel := opContext()
defer cancel() defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g
WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL) WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL OR g.imported_by IS NOT NULL)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM users u SELECT 1 FROM users u
WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by) WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by, g.imported_by)
) )
AND g.start_time_utc < $1 AND g.start_time_utc < $1
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
+32 -1
View File
@@ -47,6 +47,20 @@ type GameRecord struct {
ConcessionTermination string ConcessionTermination string
ConcessionPly int ConcessionPly int
ConcessionTimeUTC *time.Time ConcessionTimeUTC *time.Time
// ImportedBy is the account that imported the game from PGN; empty for
// a game played here. Event, Site and Round come from its tags.
ImportedBy string
Event string
Site string
Round string
}
// Origin names how a game came to be stored.
func (r GameRecord) Origin() string {
if r.ImportedBy != "" {
return "imported"
}
return "played"
} }
// GameSummaryRecord is a game row plus its replay extent. MoveCount and // GameSummaryRecord is a game row plus its replay extent. MoveCount and
@@ -81,7 +95,7 @@ type MovePersistence struct {
// schemaVersion is the newest schema this binary understands. Migrations are // schemaVersion is the newest schema this binary understands. Migrations are
// applied in order inside one transaction; PostgreSQL DDL is transactional, so // applied in order inside one transaction; PostgreSQL DDL is transactional, so
// a failed upgrade leaves the previous version intact. // a failed upgrade leaves the previous version intact.
const schemaVersion = 4 const schemaVersion = 5
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
// released migration; append a new one. // released migration; append a new one.
@@ -227,6 +241,23 @@ CREATE TABLE password_resets (
CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32) CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32)
); );
CREATE INDEX password_resets_expires_at_idx ON password_resets (expires_at); CREATE INDEX password_resets_expires_at_idx ON password_resets (expires_at);
`,
// v5: imported games. A game read from PGN belongs to the account that
// imported it (imported_by), has no claims, and keeps the PGN's event,
// site and round for its export. Imported games are not anonymous, so
// the purge index excludes them.
`
ALTER TABLE games
ADD COLUMN imported_by uuid,
ADD COLUMN event text CHECK (char_length(event) BETWEEN 1 AND 200),
ADD COLUMN site text CHECK (char_length(site) BETWEEN 1 AND 200),
ADD COLUMN round text CHECK (char_length(round) BETWEEN 1 AND 40);
DROP INDEX games_anonymous_start_idx;
CREATE INDEX games_anonymous_start_idx ON games (start_time_utc)
WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL AND imported_by IS NULL;
CREATE INDEX games_imported_by_idx ON games (imported_by, start_time_utc DESC, game_id DESC)
WHERE imported_by IS NOT NULL;
`, `,
} }
+1 -1
View File
@@ -341,7 +341,7 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
{ {
name: "anonymous purge candidates", name: "anonymous purge candidates",
query: `SELECT game_id FROM games g query: `SELECT game_id FROM games g
WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL AND g.imported_by IS NULL
AND g.start_time_utc < $1`, AND g.start_time_utc < $1`,
args: []any{time.Now()}, args: []any{time.Now()},
want: []string{"games_anonymous_start_idx"}, want: []string{"games_anonymous_start_idx"},
+3 -2
View File
@@ -150,8 +150,9 @@ func (s *Store) DeleteAccount(userID string) error {
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END, white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END, white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END, black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END,
WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil { imported_by = CASE WHEN imported_by = $1 THEN NULL ELSE imported_by END
WHERE white_claimed_by = $1 OR black_claimed_by = $1 OR imported_by = $1`, userID); err != nil {
return err return err
} }
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID) result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
+255 -12
View File
@@ -124,7 +124,7 @@ function setModalMessage(elementId, message, type = 'error') {
const el = $(elementId); const el = $(elementId);
if (!el) return; if (!el) return;
el.textContent = message; el.textContent = message;
el.classList.remove('error', 'success'); el.classList.remove('error', 'success', 'hint');
el.classList.add('show', type); el.classList.add('show', type);
} }
@@ -132,7 +132,7 @@ function clearModalMessage(elementId) {
const el = $(elementId); const el = $(elementId);
if (!el) return; if (!el) return;
el.textContent = ''; el.textContent = '';
el.classList.remove('show', 'error', 'success'); el.classList.remove('show', 'error', 'success', 'hint');
} }
function anyModalOpen() { function anyModalOpen() {
@@ -377,7 +377,15 @@ function setStorageStatus(status) {
function setMailStatus(status) { function setMailStatus(status) {
ui.mail = status; ui.mail = status;
$('forgot-row').classList.toggle('hidden', status !== 'ok'); }
// A neutral hint fills a modal's message slot until a result replaces it.
function setModalHint(elementId, text) {
const el = $(elementId);
if (!el) return;
el.textContent = text;
el.classList.remove('error', 'success');
el.classList.add('show', 'hint');
} }
function startHealthCheck() { function startHealthCheck() {
@@ -467,7 +475,7 @@ function showAccountModal() {
$('account-username').textContent = auth.username || ''; $('account-username').textContent = auth.username || '';
$('delete-account').open = false; $('delete-account').open = false;
$('delete-password').value = ''; $('delete-password').value = '';
clearModalMessage('account-modal-message'); setModalHint('account-modal-message', 'Signing out keeps your games; deleting the account removes your name from them.');
$('account-overlay').classList.add('show'); $('account-overlay').classList.add('show');
document.removeEventListener('keydown', handleAccountKeydown); document.removeEventListener('keydown', handleAccountKeydown);
document.addEventListener('keydown', handleAccountKeydown); document.addEventListener('keydown', handleAccountKeydown);
@@ -624,7 +632,11 @@ function switchAuthTab(tab) {
$('login-form').classList.toggle('hidden', tab !== 'login'); $('login-form').classList.toggle('hidden', tab !== 'login');
$('register-form').classList.toggle('hidden', tab !== 'register'); $('register-form').classList.toggle('hidden', tab !== 'register');
$('recover-form').classList.toggle('hidden', tab !== 'recover'); $('recover-form').classList.toggle('hidden', tab !== 'recover');
clearModalMessage('auth-modal-message'); setModalHint('auth-modal-message', {
login: 'Your games are kept under your account.',
register: 'A username and a password; the email is optional and lets you reset a forgotten password.',
recover: ui.mail === 'ok' ? 'The link in the email works for an hour.' : 'This server may not send email yet; the administrator can give you a reset link.',
}[tab]);
$({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus(); $({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus();
} }
@@ -1070,6 +1082,7 @@ function setNewGameModalBusy(busy) {
function showNewGameModal() { function showNewGameModal() {
if (replay.active) return; if (replay.active) return;
setModalHint('new-game-modal-message', 'Level 0 is a beginner, 20 is full strength; the search time is how long it thinks per move.');
$('modal-overlay').classList.add('show'); $('modal-overlay').classList.add('show');
document.addEventListener('keydown', handleModalKeydown); document.addEventListener('keydown', handleModalKeydown);
$('start-game-btn').focus(); $('start-game-btn').focus();
@@ -1744,6 +1757,7 @@ function renderGamesSections() {
$('games-filters').classList.toggle('hidden', !signedIn); $('games-filters').classList.toggle('hidden', !signedIn);
$('account-games-heading').textContent = 'Your games'; $('account-games-heading').textContent = 'Your games';
$('games-list').classList.toggle('hidden', !signedIn); $('games-list').classList.toggle('hidden', !signedIn);
$('games-import-btn').classList.toggle('hidden', !signedIn);
$('games-more').classList.toggle('hidden', !signedIn || !games.cursor); $('games-more').classList.toggle('hidden', !signedIn || !games.cursor);
if (!signedIn) $('games-status').textContent = ''; if (!signedIn) $('games-status').textContent = '';
renderLocalGames(); renderLocalGames();
@@ -1855,6 +1869,7 @@ function renderLocalGames() {
// affordance. The whole row is a button. // affordance. The whole row is a button.
function renderGameRow(summary) { function renderGameRow(summary) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'game-item';
const row = document.createElement('button'); const row = document.createElement('button');
row.type = 'button'; row.type = 'button';
row.className = 'game-row'; row.className = 'game-row';
@@ -1886,7 +1901,16 @@ function renderGameRow(summary) {
: `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`)); : `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`));
const when = document.createElement('div'); const when = document.createElement('div');
when.className = 'game-when'; when.className = 'game-when';
when.textContent = formatDate(summary.startTimeUtc) + (summary.gameId === live.gameId ? ' · this game' : ''); const notes = [formatDate(summary.startTimeUtc)];
if (summary.origin === 'imported') {
const origin = document.createElement('span');
origin.className = 'game-origin';
origin.textContent = summary.event ? `imported · ${summary.event}` : 'imported';
when.append(document.createTextNode(notes[0] + ' · '), origin);
} else {
if (summary.gameId === live.gameId) notes.push('this game');
when.textContent = notes.join(' · ');
}
info.append(players, outcome, when); info.append(players, outcome, when);
const go = document.createElement('span'); const go = document.createElement('span');
@@ -1898,9 +1922,203 @@ function renderGameRow(summary) {
row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`); row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`);
row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0)); row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0));
li.appendChild(row); li.appendChild(row);
// Delete, with an in-row confirmation.
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn game-delete';
del.title = summary.local ? 'Forget this game on this device' : 'Delete this game';
del.setAttribute('aria-label', `${del.title}: ${players.textContent}`);
del.innerHTML = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
del.addEventListener('click', () => confirmDeleteRow(li, del, summary));
li.appendChild(del);
return li; return li;
} }
// Replaces the row's delete button with Yes/No for one decision.
function confirmDeleteRow(li, del, summary) {
const confirm = document.createElement('div');
confirm.className = 'game-confirm';
confirm.setAttribute('role', 'group');
confirm.setAttribute('aria-label', 'Confirm deletion');
const yes = document.createElement('button');
yes.type = 'button';
yes.className = 'btn btn-danger';
yes.textContent = summary.local ? 'Forget' : 'Delete';
const no = document.createElement('button');
no.type = 'button';
no.className = 'btn btn-secondary';
no.textContent = 'Keep';
const restore = () => { confirm.replaceWith(del); del.focus(); };
no.addEventListener('click', restore);
yes.addEventListener('click', async () => {
yes.disabled = true;
no.disabled = true;
const ok = await deleteGame(summary);
if (!ok) restore();
});
confirm.append(yes, no);
del.replaceWith(confirm);
yes.focus();
}
// Deletes a game from the account list, or forgets a device entry. Returns
// whether the row is gone.
async function deleteGame(summary) {
if (summary.local) {
saveRecentGames(loadRecentGames().filter(g => g.gameId !== summary.gameId));
renderLocalGames();
toast('Game forgotten on this device', 'success');
return true;
}
let response;
try {
response = await apiJSON('DELETE', `/api/users/me/games/${summary.gameId}`);
} catch (error) {
handleApiError('delete game', error);
return false;
}
if (response.status === 401) {
clearAuthState();
return false;
}
if (!response.ok) {
handleApiError('delete game', null, response);
return false;
}
let outcome = 'game';
try {
outcome = (await response.json()).removed || 'game';
} catch { /* the row is gone either way */ }
games.items = games.items.filter(g => g.gameId !== summary.gameId);
renderGamesList();
toast(outcome === 'claim' ? 'Removed from your games; the other player keeps it' : 'Game deleted', 'success', 2500);
if (replay.gameId === summary.gameId && outcome === 'game') exitReplay({ push: false });
if (live.gameId === summary.gameId && outcome === 'game') dropLiveGame();
ui.historyCache.delete(summary.gameId);
return true;
}
// The live game was deleted from storage: the board returns to no game.
function dropLiveGame() {
stopPolling();
live.gameId = null;
live.fen = null;
live.state = null;
live.moves = [];
live.san = [];
live.lastMove = null;
live.isLocked = false;
live.selectedSquare = null;
if (!replay.active) renderPosition(START_FEN);
renderLiveMoves();
updateTurnStrip();
updateGameControls();
}
// --- import: a PGN pasted or picked from a file, stored under the account
const importState = { busy: false };
function openImportModal() {
if (!auth.token) {
showAuthModal();
return;
}
importState.busy = false;
$('import-pgn').value = '';
$('import-file').value = '';
setModalHint('import-modal-message', 'One game: tags in brackets, then the moves. Comments and variations are dropped; every move is checked.');
$('import-overlay').classList.add('show');
document.removeEventListener('keydown', handleImportKeydown);
document.addEventListener('keydown', handleImportKeydown);
$('import-pgn').focus();
}
function closeImportModal() {
if (importState.busy) return;
$('import-overlay').classList.remove('show');
document.removeEventListener('keydown', handleImportKeydown);
}
function handleImportKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
closeImportModal();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitImport();
}
}
function setImportBusy(busy) {
importState.busy = busy;
for (const id of ['import-pgn', 'import-file', 'import-submit-btn', 'import-cancel-btn']) $(id).disabled = busy;
}
// A picked file fills the text box; the size limit is the server's.
function readImportFile() {
const file = $('import-file').files?.[0];
if (!file) return;
if (file.size > 65536) {
setModalMessage('import-modal-message', 'That file is larger than 64 KiB; one game is a few kilobytes', 'error');
return;
}
const reader = new FileReader();
reader.onload = () => {
$('import-pgn').value = String(reader.result || '');
setModalHint('import-modal-message', `${file.name} loaded; press Import.`);
};
reader.onerror = () => setModalMessage('import-modal-message', 'Could not read the file', 'error');
reader.readAsText(file);
}
async function submitImport() {
if (importState.busy) return;
const pgn = $('import-pgn').value.trim();
if (!pgn) {
setModalMessage('import-modal-message', 'Paste a PGN or pick a file', 'error');
$('import-pgn').focus();
return;
}
setImportBusy(true);
clearModalMessage('import-modal-message');
let response;
try {
response = await apiJSON('POST', '/api/games/import', { pgn });
} catch (error) {
setImportBusy(false);
setModalMessage('import-modal-message', handleApiError('import', error).statusMessage, 'error');
return;
}
setImportBusy(false);
if (response.status === 401) {
clearAuthState();
setModalMessage('import-modal-message', 'Sign in again to import', 'error');
return;
}
if (!response.ok) {
const err = await parseErrorResponse(response);
setModalMessage('import-modal-message', response.status === 400
? `Not a valid game: ${err.details || err.error}`
: response.status === 429 ? 'Too many imports; wait a minute' : (err.error || `Server error (${response.status})`), 'error');
$('import-pgn').focus();
return;
}
let result;
try {
result = await response.json();
} catch {
setModalMessage('import-modal-message', 'Unexpected response from server', 'error');
return;
}
closeImportModal();
toast(`Imported, ${Math.ceil((result.moveCount || 0) / 2)} moves`, 'success', 2500);
games.cursor = '';
await loadGames(true, true);
enterReplay(result.gameId, 0);
}
// A side's name in the list, "You" for the signed-in user's claim. // A side's name in the list, "You" for the signed-in user's claim.
function sideLabel(player) { function sideLabel(player) {
if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You'; if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You';
@@ -1984,6 +2202,14 @@ function defaultFlip(history) {
return white?.type === 2 && black?.type !== 2; return white?.type === 2 && black?.type !== 2;
} }
// The exit button's text, its short form for a phone's row, and its
// accessible name for the row that shows the icon alone.
function setReplayExitLabel(text, short) {
$('replay-exit-label').textContent = text;
$('replay-exit-short').textContent = short;
$('replay-exit').setAttribute('aria-label', text);
}
function exitReplay({ push = true } = {}) { function exitReplay({ push = true } = {}) {
if (!replay.active) return; if (!replay.active) return;
stopAutoplay(); stopAutoplay();
@@ -2003,7 +2229,8 @@ function exitReplay({ push = true } = {}) {
} }
function renderReplayLoading() { function renderReplayLoading() {
$('replay-result').textContent = ''; $('replay-score').textContent = '';
$('replay-term').textContent = '';
$('replay-ply').textContent = ''; $('replay-ply').textContent = '';
$('replay-players').textContent = 'Loading game…'; $('replay-players').textContent = 'Loading game…';
$('replay-meta').textContent = replay.gameId || ''; $('replay-meta').textContent = replay.gameId || '';
@@ -2011,7 +2238,7 @@ function renderReplayLoading() {
grid.innerHTML = ''; grid.innerHTML = '';
$('replay-moves-empty').textContent = 'Loading…'; $('replay-moves-empty').textContent = 'Loading…';
setReplayNavEnabled(false); setReplayNavEnabled(false);
$('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay'; setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close');
} }
function renderReplayError(message) { function renderReplayError(message) {
@@ -2032,20 +2259,23 @@ function setReplayNavEnabled(enabled) {
function renderReplayHead() { function renderReplayHead() {
const h = replay.history; const h = replay.history;
const finished = !!h.result; const finished = !!h.result;
$('replay-result').textContent = finished // Score and termination apart: a large-font phone shows the score alone.
? `${h.pgnResult} · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}` $('replay-score').textContent = finished ? h.pgnResult : 'Ongoing';
: 'Ongoing'; $('replay-term').textContent = finished
? ` · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}`
: '';
$('replay-players').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`; $('replay-players').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`;
$('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`; $('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`;
const plies = h.moves.length; const plies = h.moves.length;
const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`]; const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`];
if (h.origin === 'imported') meta.unshift(h.event || 'imported');
if (h.concession && h.concession.termination) { if (h.concession && h.concession.termination) {
meta.push(h.concession.termination === 'agreement' meta.push(h.concession.termination === 'agreement'
? `draw agreed at ply ${h.concession.ply}` ? `draw agreed at ply ${h.concession.ply}`
: `resignation at ply ${h.concession.ply}`); : `resignation at ply ${h.concession.ply}`);
} }
$('replay-meta').textContent = meta.join(' · '); $('replay-meta').textContent = meta.join(' · ');
$('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay'; setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close');
setReplayNavEnabled(true); setReplayNavEnabled(true);
} }
@@ -2352,6 +2582,19 @@ function bindEvents() {
$('promotion-overlay').addEventListener('click', (e) => { $('promotion-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closePromotion(null); if (e.target === e.currentTarget) closePromotion(null);
}); });
// A click on a backdrop closes the dialog, unless a request is in flight.
const backdrop = (id, close) => $(id).addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
backdrop('auth-modal-overlay', () => { if (!auth.busy) hideAuthModal(); });
backdrop('account-overlay', hideAccountModal);
backdrop('modal-overlay', () => { if (!live.newGameBusy) hideNewGameModal(); });
backdrop('resign-overlay', hideResignModal);
backdrop('reset-overlay', closeResetModal);
backdrop('import-overlay', closeImportModal);
$('games-import-btn').addEventListener('click', openImportModal);
$('import-submit-btn').addEventListener('click', submitImport);
$('import-cancel-btn').addEventListener('click', closeImportModal);
$('import-file').addEventListener('change', readImportFile);
$('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); }); $('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); });
$('games-close').addEventListener('click', () => closeGames()); $('games-close').addEventListener('click', () => closeGames());
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>Chess</title> <title>Chess</title>
<!-- The brand mark as the tab icon, inline so no request leaves the page
(the host answers /favicon.ico with its 404 page) and CSP img-src data: covers it. -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='41 34 430 430'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='20%' y1='0%' x2='80%' y2='100%'%3E%3Cstop offset='0%' stop-color='%23C8CDD6'/%3E%3Cstop offset='45%' stop-color='%238B929E'/%3E%3Cstop offset='100%' stop-color='%234F545D'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cg fill='url(%23g)' stroke='%2314161A' stroke-width='4' stroke-linejoin='round'%3E%3Cpath d='M136 450Q256 465 376 450L346 410Q256 420 166 410Z'/%3E%3Cpath d='M166 410Q256 420 346 410L316 370Q256 380 196 370Z'/%3E%3Cpath d='M196 370Q256 380 316 370C300 290 286 230 282 205H230C226 230 212 290 196 370Z'/%3E%3C/g%3E%3Cpath d='M330 90Q265 96 230 120Q265 144 335 150Z' fill='%23111318'/%3E%3Cg stroke='%23F0F2F5' stroke-width='6' stroke-linecap='round'%3E%3Cpath d='M263.17 85V155M299.33 85V155'/%3E%3C/g%3E%3Cg fill='url(%23g)' stroke='%2314161A' stroke-width='4' stroke-linejoin='round'%3E%3Cpath d='M226 200C150 180 150 70 256 40C290 50 315 65 330 90Q265 96 230 120Q265 144 335 150C335 170 315 190 286 200Z'/%3E%3Crect x='216' y='195' width='80' height='16' rx='8'/%3E%3C/g%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body data-mode="live"> <body data-mode="live">
@@ -98,7 +101,7 @@
<div class="replay-head"> <div class="replay-head">
<div class="replay-title-row"> <div class="replay-title-row">
<span class="badge" id="replay-badge">Replay</span> <span class="badge" id="replay-badge">Replay</span>
<span class="replay-result" id="replay-result"></span> <span class="replay-result" id="replay-result"><span id="replay-score"></span><span class="replay-term" id="replay-term"></span></span>
<span class="replay-ply" id="replay-ply" aria-hidden="true"></span> <span class="replay-ply" id="replay-ply" aria-hidden="true"></span>
</div> </div>
<div class="replay-players" id="replay-players"></div> <div class="replay-players" id="replay-players"></div>
@@ -155,8 +158,12 @@
<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> <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> <span>Link</span>
</button> </button>
<!-- A row of its own beside the board; on a phone it shares this row. -->
<button id="replay-exit" class="btn btn-secondary btn-exit" type="button" aria-label="Close replay">
<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>
<span id="replay-exit-label">Close replay</span><span class="label-short" id="replay-exit-short" aria-hidden="true">Close</span>
</button>
</div> </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 id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
</div> </div>
</aside> </aside>
@@ -168,6 +175,10 @@
<div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title"> <div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title">
<div class="modal-head"> <div class="modal-head">
<h2 id="games-title">My games</h2> <h2 id="games-title">My games</h2>
<button id="games-import-btn" class="btn btn-ghost btn-sm hidden" type="button" title="Store a game from PGN">
<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>Import PGN</span>
</button>
<button id="games-close" class="btn btn-icon" type="button" aria-label="Close"> <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> <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> </button>
@@ -225,7 +236,7 @@
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button> <button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button> <button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div> </div>
<p class="auth-aside hidden" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p> <p class="auth-aside" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
</div> </div>
<div id="recover-form" class="auth-form hidden"> <div id="recover-form" class="auth-form hidden">
@@ -261,6 +272,31 @@
</div> </div>
</div> </div>
<!-- Import Modal: a PGN game stored under the account -->
<div id="import-overlay" class="modal-overlay">
<div class="modal import-modal" role="dialog" aria-modal="true" aria-labelledby="import-title">
<h2 id="import-title">Import a game</h2>
<div id="import-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="form-group">
<label for="import-pgn">PGN (one game: tags, then the moves)</label>
<textarea id="import-pgn" class="fen-input pgn-input" rows="8" spellcheck="false"
placeholder='[White "Spassky, Boris"]
[Black "Fischer, Robert"]
[Result "1-0"]
1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0'></textarea>
</div>
<div class="form-group form-row">
<label for="import-file" class="file-label">Or pick a .pgn file</label>
<input type="file" id="import-file" accept=".pgn,text/plain,application/x-chess-pgn">
</div>
<div class="modal-buttons">
<button id="import-submit-btn" class="btn btn-primary" type="button">Import</button>
<button id="import-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
<!-- Reset Modal: opened by the link in the recovery email (?reset=<token>) --> <!-- Reset Modal: opened by the link in the recovery email (?reset=<token>) -->
<div id="reset-overlay" class="modal-overlay"> <div id="reset-overlay" class="modal-overlay">
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title"> <div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
@@ -1,12 +1,8 @@
/* Chess web client. Dark theme matching lixen.com. The board colours and /* Chess web client. Dark theme matching lixen.com. The board colours and
piece glyph treatment are fixed (see pieceMap in app.js); everything else is piece glyph treatment are fixed (see pieceMap in app.js); everything else is
open. Styles live here: the host's CSP has style-src 'unsafe-inline', but open. State is classes and data attributes, never inline style. Two layouts
state is expressed through classes and data attributes, never inline style. share the markup: board beside the panel from 700px up, board over the
panel below; the host's embed frame switches its height at that width. */
Two layouts share the markup: board beside the panel from 979px up, and
board over the panel below (the host's embed frame switches its height at
the same width). Both must fit the host's frame: 30rem tall side by side,
46rem (capped at 80svh) stacked. */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
@@ -45,14 +41,15 @@
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
/* Side-by-side sizes: the board edge includes its coordinate margin. It /* Side by side, the board (its edge holds the coordinates) takes the
grows with the window but never past what is left under the top bar height under the top bar, or the width the panel's minimum leaves, and
(the host's 30rem frame leaves 416px), so the embed keeps a 360px the panel takes the rest of the row. 3.5rem and 2rem are the app's
board while a full window gets up to 560px. */ padding and gaps (8px each side, a gap of up to 2rem) plus 8px of slack;
the host's embed sizes its frame to this formula. */
--topbar-height: 2.5rem; --topbar-height: 2.5rem;
--board-edge: clamp(360px, min(32vw, calc(100vh - 64px)), 560px); --panel-min: 260px;
--board-pad: clamp(16px, 1.04vw, 20px); --board-edge: clamp(280px, min(100vw - var(--panel-min) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
--panel-width: clamp(280px, 20vw, 380px); --board-pad: clamp(14px, 1.04vw, 20px);
} }
html, body { html, body {
@@ -396,16 +393,29 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; }
/* --- Panel ----------------------------------------------------------- */ /* --- Panel ----------------------------------------------------------- */
/* Up to 48rem: a full window on a large screen centres the pair rather than
stretching the controls across it. */
.panel { .panel {
flex: none; flex: 1 1 0;
width: var(--panel-width); min-width: var(--panel-min);
max-width: 48rem;
height: var(--board-edge); height: var(--board-edge);
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
container-type: inline-size;
} }
/* A narrow panel shows the export buttons as icons. */
@container (max-width: 300px) {
.toolbar .btn-tool span { display: none; }
.toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
.controls .btn { font-size: 0.78rem; padding: 0.5rem 0.2rem; }
}
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
.pane { .pane {
height: 100%; height: 100%;
padding: 0.75rem; padding: 0.75rem;
@@ -535,6 +545,11 @@ body[data-mode="replay"] .pane-live { display: none; }
/* --- Replay pane ----------------------------------------------------- */ /* --- Replay pane ----------------------------------------------------- */
/* The export row wraps the exit button onto a row of its own. */
.pane-replay .toolbar { flex-wrap: wrap; row-gap: 0.6rem; }
.pane-replay .btn-exit { flex: 1 0 100%; }
.pane-replay .btn-exit .label-short { display: none; }
.replay-head { .replay-head {
flex: none; flex: none;
display: flex; display: flex;
@@ -566,7 +581,7 @@ body[data-mode="replay"] .pane-live { display: none; }
} }
.replay-result { color: var(--fg); font-weight: 600; } .replay-result { color: var(--fg); font-weight: 600; }
.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; } .replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; }
.replay-players { .replay-players {
color: var(--fg); color: var(--fg);
@@ -822,6 +837,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
} }
.modal-message.show { visibility: visible; } .modal-message.show { visibility: visible; }
.modal-message.hint { color: var(--muted); }
.modal-message.error { background: rgba(247, 118, 142, 0.12); color: var(--red); border-color: rgba(247, 118, 142, 0.4); } .modal-message.error { background: rgba(247, 118, 142, 0.12); color: var(--red); border-color: rgba(247, 118, 142, 0.4); }
.modal-message.success { background: rgba(158, 206, 106, 0.12); color: var(--green); border-color: rgba(158, 206, 106, 0.4); } .modal-message.success { background: rgba(158, 206, 106, 0.12); color: var(--green); border-color: rgba(158, 206, 106, 0.4); }
@@ -843,6 +859,11 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.confirm-modal { max-width: 320px; } .confirm-modal { max-width: 320px; }
.import-modal { max-width: 460px; }
.pgn-input { min-height: 9rem; }
.file-label { margin: 0; }
#import-file { max-width: 55%; font-size: 0.8rem; color: var(--muted); }
.promotion-modal { max-width: 320px; } .promotion-modal { max-width: 320px; }
.promotion-choices { .promotion-choices {
@@ -1020,6 +1041,38 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
align-items: center; align-items: center;
} }
/* A row's delete control sits beside the row; a confirmation replaces it. */
.game-item {
display: flex;
align-items: stretch;
gap: 0.35rem;
}
.game-item .game-row { flex: 1; min-width: 0; }
.game-delete {
flex: none;
width: 2.2rem;
padding: 0;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--muted);
}
.game-delete:hover:not(:disabled) { color: var(--red); border-color: var(--red); transform: none; }
.game-confirm {
flex: none;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3rem;
padding: 0 0.2rem;
}
.game-confirm .btn { padding: 0.3rem 0.5rem; font-size: 0.78rem; }
.game-origin { color: var(--periwinkle); }
.games-status { .games-status {
margin: 0.4rem 0; margin: 0.4rem 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -1033,112 +1086,27 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
#games-more { margin-top: 0.4rem; } #games-more { margin-top: 0.4rem; }
#games-more.hidden { display: none; } #games-more.hidden { display: none; }
/* --- Stacked layout (board over panel) -------------------------------- */
@media (min-width: 641px) and (max-width: 978px) {
:root {
--panel-width: clamp(360px, 45vw, 440px);
--panel-height: 230px;
/* Board edge = the smaller of the width-derived size and the height
that is left: topbar 40 + gaps 16 + panel 230 + app padding 16 +
slack 40 = 342. A short frame shrinks the board before anything
scrolls. */
--board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 342px)));
--board-pad: clamp(16px, 2.04vw, 20px);
}
body { overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
.app { height: auto; min-height: 100%; }
.workspace {
flex-direction: column;
gap: 0.5rem;
overflow: visible;
}
.panel {
width: var(--panel-width);
height: var(--panel-height);
}
.pane {
display: grid;
grid-template-columns: clamp(150px, 18.4vw, 180px) minmax(0, 1fr);
grid-template-rows: min-content 1fr;
gap: 0.5rem 0.75rem;
padding: 0.6rem;
}
.pane > * { min-width: 0; }
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
/* Four export buttons in the narrow right column: icons only. */
.pane-replay .toolbar .btn-tool span { display: none; }
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; }
.pane-live .turn-strip { grid-column: 1; grid-row: 1; }
.pane-live .controls {
grid-column: 1;
grid-row: 2;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
align-self: stretch;
}
.pane-live .controls .btn { padding: 0.3rem 0.2rem; font-size: 0.78rem; min-height: 2rem; }
.pane-live .moves { grid-column: 2; grid-row: 1 / 3; }
.pane-live .toolbar { grid-column: 2; grid-row: 3; }
.pane-live { grid-template-rows: min-content 1fr min-content; }
.pane-live .controls { grid-row: 2 / 4; }
/* Replay: head, navigation and the exit button down the left; the move
list with the export row on the right. */
.pane-replay { grid-template-rows: min-content min-content 1fr min-content; }
.pane-replay .replay-head { grid-column: 1; grid-row: 1; padding: 0.3rem 0.5rem; }
.pane-replay .replay-nav {
grid-column: 1;
grid-row: 2;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.25rem;
}
.pane-replay .replay-nav .speed { grid-column: 1 / 5; margin: 0; }
.pane-replay .replay-nav .speed select { width: 100%; }
.pane-replay #replay-exit { grid-column: 1; grid-row: 3 / 5; align-self: end; padding: 0.35rem; font-size: 0.78rem; }
.pane-replay .moves { grid-column: 2; grid-row: 1 / 4; }
.pane-replay .toolbar { grid-column: 2; grid-row: 4; }
.pane-replay .btn-nav { width: 100%; height: 1.65rem; }
.pane-replay .btn-nav .icon { width: 1rem; height: 1rem; }
.pane-replay .speed select { height: 1.65rem; font-size: 0.72rem; padding: 0 0.2rem; }
.pane-replay .replay-meta { display: none; }
.pane-replay .replay-title-row { flex-wrap: wrap; gap: 0.2rem 0.4rem; }
.pane-replay .replay-players { font-size: 0.76rem; }
.pane-replay .replay-result { font-size: 0.76rem; }
.move-grid { grid-template-columns: 2rem 1fr 1fr; font-size: 0.84rem; }
.btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; }
}
/* --- Phone layout: full-width rows, edge to edge ------------------------ */ /* --- Phone layout: full-width rows, edge to edge ------------------------ */
@media (max-width: 640px) { @media (max-width: 699px) {
:root { :root {
/* Reserve under the board: top bar 40 + gap 6 + turn strip 32 + /* Reserve under the board, in rem so a larger system font grows it:
controls 36 + move list 96 + toolbar 36 + gaps and padding 26 = top bar 2.5 + gap 0.4 + turn strip 2 + controls 2.25 + move list 4
276. Small viewport units, so the browser's bars never cover the + toolbar 2.25 + gaps and padding 2.35 = 15.75rem. Small viewport
panel. The replay pane is taller (head, navigation, export, exit), units, so the browser's bars never cover the panel. At a 1.5x
so a replay reserves more and the board gives way on a short system font (rem 24px) on a 412x734 viewport this still leaves a
screen rather than the controls dropping below the fold. */ 356px board; the move list scrolls inside its 4rem. The replay
--board-edge: max(240px, min(100vw, calc(100svh - 284px))); pane is taller (head, navigation, export, exit), so a replay
reserves more and the board gives way on a short screen rather
than the controls dropping below the fold. */
--board-edge: max(240px, min(100vw, calc(100svh - 15.75rem)));
--board-pad: 14px; --board-pad: 14px;
--panel-width: 100%;
--panel-height: auto;
--topbar-height: 2.5rem;
} }
body { overflow-y: auto; overflow-x: hidden; } body { overflow-y: auto; overflow-x: hidden; }
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 372px))); } /* A replay's panel: head 2.3 + list 4 + navigation 2.2 + export row 2.25
+ gaps and padding 2.85, under the top bar 2.9 = 16.5rem. */
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 16.5rem))); }
/* The app is exactly the viewport, so the panel and its move list shrink /* The app is exactly the viewport, so the panel and its move list shrink
to fit; only when their minimums do not fit does the page scroll. */ to fit; only when their minimums do not fit does the page scroll. */
@@ -1174,19 +1142,27 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.coordinates.ranks { left: -14px; } .coordinates.ranks { left: -14px; }
.coordinates.files { top: -15px; } .coordinates.files { top: -15px; }
/* The panel never clips: what the viewport cannot hold scrolls the page. */
.panel { .panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
max-width: none;
min-height: 0; min-height: 0;
height: auto; height: auto;
border-radius: 0; border-radius: 0;
border-left: none; border-left: none;
border-right: none; border-right: none;
border-bottom: none; border-bottom: none;
overflow: visible;
container-type: normal;
} }
/* min-height 0: a flex item's automatic minimum is its content height,
which would let a long move list push the controls below the fold
instead of scrolling inside its box. */
.pane, .pane-live, .pane-replay { .pane, .pane-live, .pane-replay {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
@@ -1194,6 +1170,9 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
gap: 0.4rem; gap: 0.4rem;
padding: 0.5rem 0.6rem 0.6rem; padding: 0.5rem 0.6rem 0.6rem;
height: auto; height: auto;
min-height: 0;
container-type: inline-size;
container-name: pane;
} }
.pane > * { min-width: 0; } .pane > * { min-width: 0; }
@@ -1207,14 +1186,29 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
} }
.pane-live .controls .btn { padding: 0.5rem 0.2rem; font-size: 0.85rem; min-height: 2.25rem; height: auto; } .pane-live .controls .btn { padding: 0.5rem 0.2rem; font-size: 0.85rem; min-height: 2.25rem; height: auto; }
.moves { flex: 1 1 auto; min-height: 96px; } .moves { flex: 1 1 auto; min-height: 4rem; }
.move-grid { grid-template-columns: 2.2rem 1fr 1fr; font-size: 0.9rem; } .move-grid { grid-template-columns: 2.2rem 1fr 1fr; font-size: 0.9rem; }
.toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; } .toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; }
/* Replay: head, list, navigation, export and exit as rows. */ /* Replay: a one-line head (result, players, ply; no badge, no meta),
.pane-replay .replay-head { padding: 0.35rem 0.6rem; } the list, the navigation row, then export and exit on one row. */
.pane-replay .replay-head {
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
min-height: 2.3rem;
}
.pane-replay .replay-title-row { display: contents; }
.pane-replay .badge { display: none; }
.pane-replay .replay-result { flex: none; white-space: nowrap; }
.pane-replay .replay-players { order: 1; flex: 1 1 auto; }
.pane-replay .replay-ply { order: 2; margin-left: auto; }
.pane-replay .replay-meta { display: none; } .pane-replay .replay-meta { display: none; }
.pane-replay .btn-exit { flex: 1.3 1 0; min-width: 0; padding: 0.45rem 0.3rem; font-size: 0.82rem; }
.pane-replay .btn-exit #replay-exit-label { display: none; }
.pane-replay .btn-exit .label-short { display: inline; }
.pane-replay .replay-nav { .pane-replay .replay-nav {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr) 1.4fr 1fr; grid-template-columns: repeat(5, 1fr) 1.4fr 1fr;
@@ -1223,7 +1217,6 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.pane-replay .replay-nav .speed { margin: 0; } .pane-replay .replay-nav .speed { margin: 0; }
.pane-replay .replay-nav .speed select { width: 100%; } .pane-replay .replay-nav .speed select { width: 100%; }
.pane-replay .btn-nav { width: 100%; height: 2.2rem; } .pane-replay .btn-nav { width: 100%; height: 2.2rem; }
.pane-replay #replay-exit { padding: 0.5rem; font-size: 0.85rem; }
.games-modal { padding: 0.75rem; } .games-modal { padding: 0.75rem; }
.game-row { gap: 0.5rem; padding: 0.4rem; } .game-row { gap: 0.5rem; padding: 0.4rem; }
@@ -1232,10 +1225,15 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.modal-overlay { padding: 0.5rem; } .modal-overlay { padding: 0.5rem; }
} }
/* Very narrow phones: icon-only export buttons. */ /* Narrow phones, and phones whose system font is large: the pane is a
@media (max-width: 380px) { container on phones and rem in a container query is the root font size,
.pane-replay .toolbar .btn-tool span { display: none; } so 24rem is 384px at the default size and 600px at a 1.5x font. Then the
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; } export buttons show icons alone and the replay head keeps the score and
the players but not the termination. */
@container pane (max-width: 24rem) {
.pane-replay .toolbar .btn-tool span, .pane-replay .btn-exit span, .pane-replay .btn-exit .label-short { display: none; }
.pane-replay .toolbar .btn-tool .icon, .pane-replay .btn-exit .icon { width: 1.2rem; height: 1.2rem; }
.pane-replay .replay-term { display: none; }
.pane-live .controls .btn { font-size: 0.8rem; } .pane-live .controls .btn { font-size: 0.8rem; }
} }
+13 -3
View File
@@ -17,13 +17,23 @@ scripts pass it to both `chess-server` and `psql`. The test server drops every
chess table in that database on start and exit, so never point it at real data. chess table in that database on start and exit, so never point it at real data.
```bash ```bash
# One-time setup as a PostgreSQL superuser # One-time setup, either way:
psql -U postgres -c "CREATE ROLE chess_test LOGIN PASSWORD 'chess_test'"
psql -U postgres -c "CREATE DATABASE chess_test OWNER chess_test"
# a) your own OS account owns the test database (peer authentication over
# the local socket, no password; create the role first if needed:
# sudo -u postgres createuser "$USER")
createdb chess_test
export CHESS_TEST_DSN='dbname=chess_test'
# b) a dedicated role with a password, over TCP (what CI uses)
sudo -u postgres psql -c "CREATE ROLE chess_test LOGIN PASSWORD 'chess_test'" \
-c "CREATE DATABASE chess_test OWNER chess_test"
export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable' export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable'
``` ```
`run-test-server.sh` prints these steps when the variable is unset. The DSN
is libpq keyword/value or URL form, as for the server's `-dsn`.
The same variable enables the Go database tests (`go test ./...`), which create The same variable enables the Go database tests (`go test ./...`), which create
and drop an isolated schema per test and are skipped when it is unset. and drop an isolated schema per test and are skipped when it is unset.
+81 -1
View File
@@ -167,7 +167,7 @@ test.describe('web replay', () => {
await expect(page.locator('#replay-ply')).toHaveText('2 / 4'); 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('0-1');
await expect(page.locator('#replay-result')).toContainText('Checkmate'); await expect(page.locator('#replay-result')).toContainText('Checkmate');
await expect(page.locator('#replay-exit')).toHaveText('Close replay'); await expect(page.locator('#replay-exit-label')).toHaveText('Close replay');
await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#'); await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#');
await page.keyboard.press('End'); await page.keyboard.press('End');
await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/); await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/);
@@ -179,6 +179,55 @@ test.describe('web replay', () => {
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
}); });
test('import a PGN, replay it, delete it', async ({ page, request }) => {
const user = await register(page, request);
await page.goto('/');
await expect(page.locator('#auth-label')).toHaveText(user.username);
await page.click('#games-btn');
await expect(page.locator('#games-import-btn')).toBeVisible();
await page.click('#games-import-btn');
await expect(page.locator('#import-overlay')).toHaveClass(/show/);
await page.fill('#import-pgn', 'not a game');
await page.click('#import-submit-btn');
await expect(page.locator('#import-modal-message')).toContainText('Not a valid game');
await page.fill('#import-pgn', `[Event "Test event"]
[White "Spassky, Boris"]
[Black "Fischer, Robert"]
[Date "1972.07.11"]
[Result "1-0"]
1. d4 Nf6 2. c4 e6 3. Nf3 d5 {comment} (3... c5) 1-0
`);
await page.click('#import-submit-btn');
await expect(page.locator('#import-overlay')).not.toHaveClass(/show/);
// The import opens as a replay with the tags shown.
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
await expect(page.locator('#replay-players')).toHaveText('Spassky, Boris vs Fischer, Robert');
await expect(page.locator('#replay-meta')).toContainText('Test event');
await expect(page.locator('#replay-result')).toContainText('1-0');
await expect(page.locator('#replay-moves .move')).toHaveCount(6);
const gameId = await page.evaluate(() => replay.gameId);
const pgn = await (await request.get(`${API}/api/games/${gameId}/pgn`)).text();
expect(pgn).toContain('[Event "Test event"]');
expect(pgn).toContain('{ White wins by resignation. } 1-0');
// Listed as imported; delete it from the row.
await page.click('#games-btn');
const item = page.locator('#games-list .game-item').first();
await expect(item.locator('.game-row')).toContainText('Spassky, Boris vs Fischer, Robert');
await expect(item.locator('.game-origin')).toContainText('imported · Test event');
await item.locator('.game-delete').click();
await item.locator('.game-confirm .btn-secondary').click(); // Keep
await expect(item.locator('.game-delete')).toBeVisible();
await item.locator('.game-delete').click();
await item.locator('.game-confirm .btn-danger').click();
await expect(page.locator('#games-list .game-item')).toHaveCount(0);
await expect(page.locator('#games-status')).toContainText('No stored games');
// The replay of the deleted game closed and the game is gone.
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
expect((await request.get(`${API}/api/games/${gameId}/history`)).status()).toBe(404);
});
test('a phone fits the board and the controls without scrolling', async ({ browser }) => { test('a phone fits the board and the controls without scrolling', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 412, height: 780 }, isMobile: true, hasTouch: true }); const context = await browser.newContext({ viewport: { width: 412, height: 780 }, isMobile: true, hasTouch: true });
const page = await context.newPage(); const page = await context.newPage();
@@ -203,6 +252,31 @@ test.describe('web replay', () => {
expect(await fits()).toEqual({ w: true, h: true }); expect(await fits()).toEqual({ w: true, h: true });
await expect(page.locator('#replay-exit')).toBeInViewport(); await expect(page.locator('#replay-exit')).toBeInViewport();
await expect(page.locator('#replay-link')).toBeInViewport(); await expect(page.locator('#replay-link')).toBeInViewport();
// A long game scrolls inside its move list, not the page.
const username = `phone${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
const auth = await (await context.request.post(`${API}/api/auth/register`, { data: { username, password: 'Passw0rd123' } })).json();
const moves = ['e4 e5', 'Nf3 Nc6', 'Bb5 a6', 'Ba4 Nf6', 'O-O Be7', 'Re1 b5', 'Bb3 d6', 'c3 O-O', 'h3 Nb8', 'd4 Nbd7',
'c4 c6', 'cxb5 axb5', 'Nc3 Bb7', 'Bg5 b4', 'Nb1 h6', 'Bh4 c5', 'dxe5 Nxe4', 'Bxe7 Qxe7', 'exd6 Qf6', 'Nbd2 Nxd6'];
const pgn = moves.map((m, i) => `${i + 1}. ${m}`).join(' ') + ' *\n';
const imported = await (await context.request.post(`${API}/api/games/import`, {
headers: { Authorization: `Bearer ${auth.token}` }, data: { pgn } })).json();
expect(imported.moveCount).toBe(40);
await page.goto(`/?replay=${imported.gameId}&ply=40`);
await expect(page.locator('#replay-moves .move')).toHaveCount(40);
expect(await fits()).toEqual({ w: true, h: true });
await expect(page.locator('#replay-exit')).toBeInViewport();
const box = await page.locator('#replay-moves-box').evaluate(el => ({ scrolls: el.scrollHeight > el.clientHeight, atEnd: el.scrollTop > 0 }));
expect(box).toEqual({ scrolls: true, atEnd: true });
// A 1.5x system font (Android font scaling raises the root size): the
// board keeps a usable size, the export row shows icons alone and
// nothing overflows its button.
await page.evaluate(() => { document.documentElement.style.fontSize = '25px'; });
expect(await fits()).toEqual({ w: true, h: true });
await expect(page.locator('#replay-exit')).toBeInViewport();
expect((await page.locator('.board-wrapper').boundingBox()).width).toBeGreaterThanOrEqual(280);
await expect(page.locator('#replay-exit-label')).toBeHidden();
const clipped = await page.locator('.pane-replay .toolbar > *').evaluateAll(els => els.filter(el => el.scrollWidth > el.clientWidth + 1).length);
expect(clipped).toBe(0);
await context.close(); await context.close();
}); });
@@ -218,6 +292,12 @@ test.describe('web replay', () => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('#storage-dot')).toHaveAttribute('data-status', 'ok'); await expect(page.locator('#storage-dot')).toHaveAttribute('data-status', 'ok');
await page.click('#auth-btn'); await page.click('#auth-btn');
// The message slot carries a hint until a result replaces it; a click
// on the backdrop closes the dialog.
await expect(page.locator('#auth-modal-message')).toHaveClass(/hint/);
await page.mouse.click(5, 5);
await expect(page.locator('#auth-modal-overlay')).not.toHaveClass(/show/);
await page.click('#auth-btn');
// A wrong password keeps the focus in the password field. // A wrong password keeps the focus in the password field.
await page.fill('#login-identifier', username); await page.fill('#login-identifier', username);
await page.fill('#login-password', 'wrong'); await page.fill('#login-password', 'wrong');
+26 -1
View File
@@ -6,7 +6,32 @@ set -e
CHESS_SERVER_EXEC=${1:-"bin/chess-server"} CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
# A DISPOSABLE database: every chess table in it is dropped on start and exit. # A DISPOSABLE database: every chess table in it is dropped on start and exit.
# Use a libpq-compatible DSN; test-db.sh also passes it to psql. # Use a libpq-compatible DSN; test-db.sh also passes it to psql.
: "${CHESS_TEST_DSN:?set CHESS_TEST_DSN to a disposable PostgreSQL database}" if [ -z "${CHESS_TEST_DSN:-}" ]; then
cat >&2 <<'HELP'
CHESS_TEST_DSN is not set. It names a DISPOSABLE PostgreSQL database: every
chess table in it is dropped when this server starts and stops.
One-time setup, pick one:
# a) your own OS account owns a test database (peer authentication over the
# local socket; no password; the account must exist as a PostgreSQL role:
# sudo -u postgres createuser "$USER" if it does not)
createdb chess_test
export CHESS_TEST_DSN='dbname=chess_test'
# b) a dedicated role with a password, over TCP
sudo -u postgres psql -c "CREATE ROLE chess_test LOGIN PASSWORD 'chess_test'" \
-c "CREATE DATABASE chess_test OWNER chess_test"
export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable'
The same variable enables the Go database tests (go test ./...). Do not point
it at a database with real games. Then run this script again, with WEB_PORT
and MAIL_DIR to also serve the web client and enable password recovery:
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh
HELP
exit 1
fi
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}