Compare commits
6
Commits
d48a28fad6
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
3ab0efee31 | ||
|
|
a012d07cd8 | ||
|
|
0461020c46 | ||
|
|
664cf9654e | ||
|
|
e1fa5242e3 | ||
|
|
a999177f0c |
No files matched your search
@@ -43,3 +43,57 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
GOOS=freebsd GOARCH=amd64 go build -trimpath -o /dev/null ./cmd/chess-server
|
GOOS=freebsd GOARCH=amd64 go build -trimpath -o /dev/null ./cmd/chess-server
|
||||||
GOOS=js GOARCH=wasm go build -trimpath -o /dev/null ./cmd/chess-client-cli
|
GOOS=js GOARCH=wasm go build -trimpath -o /dev/null ./cmd/chess-client-cli
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: chess_test
|
||||||
|
POSTGRES_PASSWORD: chess_test
|
||||||
|
POSTGRES_DB: chess_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U chess_test -d chess_test"
|
||||||
|
--health-interval 2s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 30
|
||||||
|
env:
|
||||||
|
CHESS_TEST_DSN: postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
|
with:
|
||||||
|
go-version: '1.27.x'
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Install Stockfish
|
||||||
|
run: sudo apt-get update -qq && sudo apt-get install -y -qq stockfish
|
||||||
|
- name: Build the server
|
||||||
|
run: make server
|
||||||
|
- name: Install Playwright and Chromium
|
||||||
|
working-directory: test/e2e
|
||||||
|
run: npm install && npx playwright install --with-deps chromium
|
||||||
|
- name: Start the test server with the web client
|
||||||
|
run: |
|
||||||
|
WEB_PORT=9090 MAIL_DIR="$RUNNER_TEMP/chess-mail" LOG_LEVEL=info test/run-test-server.sh bin/chess-server > server.log 2>&1 &
|
||||||
|
for i in $(seq 1 60); do curl -sf http://localhost:8080/health && break; sleep 1; done
|
||||||
|
curl -sf http://localhost:8080/health
|
||||||
|
- name: Run the web client tests
|
||||||
|
working-directory: test/e2e
|
||||||
|
env:
|
||||||
|
CHESS_E2E_MAIL_DIR: ${{ runner.temp }}/chess-mail
|
||||||
|
run: npm test
|
||||||
|
- name: Server log
|
||||||
|
if: failure()
|
||||||
|
run: tail -n 200 server.log
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-traces
|
||||||
|
path: test/e2e/test-results/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -167,6 +167,12 @@ test-replay:
|
|||||||
test-draw:
|
test-draw:
|
||||||
test/test-draw.sh
|
test/test-draw.sh
|
||||||
|
|
||||||
|
# Web client end-to-end tests (Playwright); needs a server started with
|
||||||
|
# WEB_PORT=9090 test/run-test-server.sh and `npm install` in test/e2e.
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e:
|
||||||
|
cd test/e2e && npm test
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
.PHONY: db-init
|
.PHONY: db-init
|
||||||
db-init: server
|
db-init: server
|
||||||
@@ -251,6 +257,7 @@ help:
|
|||||||
@echo " make test-endstate Run end-state tests"
|
@echo " make test-endstate Run end-state tests"
|
||||||
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
|
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
|
||||||
@echo " make test-draw Run draw and resignation tests"
|
@echo " make test-draw Run draw and resignation tests"
|
||||||
|
@echo " make test-e2e Run the web client's Playwright tests (see test/README.md)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
||||||
@echo " make db-init Create or migrate the schema"
|
@echo " make db-init Create or migrate the schema"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
|
|||||||
- Authenticated stored-game listing with cursor pagination and filters
|
- Authenticated stored-game listing with cursor pagination and filters
|
||||||
- Configurable structured debug logs for persistence, cleanup, and engine work
|
- Configurable structured debug logs for persistence, cleanup, and engine work
|
||||||
- User management with secure Argon2id password storage and scoped JWTs
|
- User management with secure Argon2id password storage and scoped JWTs
|
||||||
|
- Password recovery by email (SMTP relay, or a directory of `.eml` files in
|
||||||
|
development) and administrative reset links
|
||||||
- Self-service account deletion: claims and names are cleared from stored games
|
- Self-service account deletion: claims and names are cleared from stored games
|
||||||
- Static, CGO-free binaries (cross-compiles for FreeBSD)
|
- Static, CGO-free binaries (cross-compiles for FreeBSD)
|
||||||
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
|
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
|
||||||
@@ -134,9 +136,12 @@ their last activity.
|
|||||||
# List all users
|
# List all users
|
||||||
./chess-server db user list
|
./chess-server db user list
|
||||||
|
|
||||||
# Update password
|
# Update password (a forgotten one too); without -password it prompts
|
||||||
./chess-server db user set-password -username alice -password NewPass456
|
./chess-server db user set-password -username alice -password NewPass456
|
||||||
|
|
||||||
|
# One-time reset link to hand to the person (what the recovery email carries)
|
||||||
|
./chess-server db user reset-link -username alice -url https://example.com/chess/web-client/
|
||||||
|
|
||||||
# Update email
|
# Update email
|
||||||
./chess-server db user set-email -username alice -email newemail@example.com
|
./chess-server db user set-email -username alice -email newemail@example.com
|
||||||
|
|
||||||
@@ -167,12 +172,16 @@ The chess server includes an embedded web UI for playing games through a browser
|
|||||||
|
|
||||||
- Visual chess board with click-to-move and a promotion picker
|
- Visual chess board with click-to-move and a promotion picker
|
||||||
- Human vs Computer gameplay, with undo, draw offers and resignation
|
- Human vs Computer gameplay, with undo, draw offers and resignation
|
||||||
- Configurable engine strength (0-20)
|
- Configurable engine strength (0-20) and custom starting positions
|
||||||
- UCI move history with move numbers
|
- Move list in standard algebraic notation; copy the FEN or the PGN
|
||||||
- FEN display and custom starting positions
|
- **My games**: a signed-in user's stored games with thumbnails, filters and
|
||||||
- Real-time server health monitoring
|
paging; games played anonymously on the device are listed for 24 hours
|
||||||
- User authentication support
|
- **Replay** of any stored game: step, autoplay, flip, keyboard navigation,
|
||||||
- Responsive design for mobile devices
|
export FEN and PGN at any ply, and shareable links
|
||||||
|
(`?replay=<gameId>&ply=<n>`)
|
||||||
|
- Server and storage health in the top bar
|
||||||
|
- Accounts: register, sign in, forgotten-password recovery by email, delete
|
||||||
|
- Responsive layout, board beside or above the panel
|
||||||
|
|
||||||
Access the UI at `http://localhost:9090` when server is running with `-serve` flag.
|
Access the UI at `http://localhost:9090` when server is running with `-serve` flag.
|
||||||
|
|
||||||
@@ -184,8 +193,8 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
|
|||||||
- [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh
|
- [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh
|
||||||
- [Architecture](./doc/architecture.md) - System design with auth layer
|
- [Architecture](./doc/architecture.md) - System design with auth layer
|
||||||
- [Development](./doc/development.md) - Build, test, and user management
|
- [Development](./doc/development.md) - Build, test, and user management
|
||||||
- [Client Guide](./doc/client.md) - Interactive debugging client
|
- [Client Guide](./doc/client.md) - Interactive debugging client, including replay
|
||||||
- [Replay Plan](./doc/todo.md) - Phased game-replay implementation and open decisions
|
- [Replay Plan](./doc/todo.md) - Game-replay phases, decisions, and what is left
|
||||||
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ func runClient() (restart bool) {
|
|||||||
Verbose: false,
|
Verbose: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize simple input scanner
|
// Initialize simple input scanner; the pager reads from it between screens
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
s.ReadLine = func() (string, bool) {
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return scanner.Text(), true
|
||||||
|
}
|
||||||
|
|
||||||
display.Println(display.Cyan, "Chess Debug Client")
|
display.Println(display.Cyan, "Chess Debug Client")
|
||||||
display.Println(display.Cyan, "API: %s", s.APIBaseURL)
|
display.Println(display.Cyan, "API: %s", s.APIBaseURL)
|
||||||
@@ -114,6 +120,12 @@ func buildPrompt(s *session.Session) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A replay replaces the turn: the live game is not what the board shows
|
||||||
|
if seg := command.ReplayPrompt(s); seg != "" {
|
||||||
|
b.Add(display.Magenta, seg)
|
||||||
|
return display.Prompt(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Add game state if available
|
// Add game state if available
|
||||||
if s.CurrentGameState != nil {
|
if s.CurrentGameState != nil {
|
||||||
turnInfo := " - Turn:"
|
turnInfo := " - Turn:"
|
||||||
|
|||||||
+142
-3
@@ -1,10 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lixenwraith/chess/internal/server/replay"
|
"github.com/lixenwraith/chess/internal/server/replay"
|
||||||
|
"github.com/lixenwraith/chess/internal/server/service"
|
||||||
"github.com/lixenwraith/chess/internal/server/storage"
|
"github.com/lixenwraith/chess/internal/server/storage"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -24,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] {
|
||||||
@@ -38,9 +41,14 @@ 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, list")
|
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list")
|
||||||
}
|
}
|
||||||
return runUser(args[1], args[2:])
|
return runUser(args[1], args[2:])
|
||||||
default:
|
default:
|
||||||
@@ -207,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
|
||||||
}
|
}
|
||||||
@@ -266,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) {
|
||||||
@@ -303,6 +398,8 @@ func runUser(subcommand string, args []string) error {
|
|||||||
return runUserSetEmail(args)
|
return runUserSetEmail(args)
|
||||||
case "set-username":
|
case "set-username":
|
||||||
return runUserSetUsername(args)
|
return runUserSetUsername(args)
|
||||||
|
case "reset-link":
|
||||||
|
return runUserResetLink(args)
|
||||||
case "list":
|
case "list":
|
||||||
return runUserList(args)
|
return runUserList(args)
|
||||||
default:
|
default:
|
||||||
@@ -310,6 +407,48 @@ func runUser(subcommand string, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runUserResetLink prints a one-time password-reset link for an account, the
|
||||||
|
// same link the recovery email carries, for an administrator to pass on when
|
||||||
|
// the server sends no mail or the account has no email. It works for an hour
|
||||||
|
// and replaces any pending link.
|
||||||
|
func runUserResetLink(args []string) error {
|
||||||
|
fs, dsn := newFlagSet("user reset-link")
|
||||||
|
username := fs.String("username", "", "Username")
|
||||||
|
userID := fs.String("id", "", "User ID")
|
||||||
|
publicURL := fs.String("url", "", "Web client address the link opens, e.g. https://example.com/chess/web-client/ (required)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base, err := url.Parse(*publicURL)
|
||||||
|
if *publicURL == "" || err != nil || base.Scheme == "" || base.Host == "" {
|
||||||
|
return fmt.Errorf("-url must be the web client's absolute address")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := openStore(*dsn, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
targetID, err := lookupUser(store, *username, *userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, hash, err := service.NewResetToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||||
|
UserID: targetID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(service.PasswordResetTTL),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to create reset: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Reset link for %s, valid until %s (one use):\n%s\n",
|
||||||
|
abbreviateID(targetID), now.Add(service.PasswordResetTTL).Format(time.RFC3339), service.ResetLink(base, token))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// readPassword prompts on the terminal without echo.
|
// readPassword prompts on the terminal without echo.
|
||||||
func readPassword(prompt string) (string, error) {
|
func readPassword(prompt string) (string, error) {
|
||||||
fmt.Print(prompt)
|
fmt.Print(prompt)
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/lixenwraith/chess/cmd/chess-server/cli"
|
"github.com/lixenwraith/chess/cmd/chess-server/cli"
|
||||||
"github.com/lixenwraith/chess/internal/server/http"
|
"github.com/lixenwraith/chess/internal/server/http"
|
||||||
|
"github.com/lixenwraith/chess/internal/server/mail"
|
||||||
"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"
|
||||||
"github.com/lixenwraith/chess/internal/server/storage"
|
"github.com/lixenwraith/chess/internal/server/storage"
|
||||||
@@ -58,6 +61,18 @@ func main() {
|
|||||||
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
||||||
dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete")
|
dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete")
|
||||||
|
|
||||||
|
// Password recovery by email: a transport (SMTP relay, or a directory
|
||||||
|
// of .eml files for development) and the web client's address, which
|
||||||
|
// 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)")
|
||||||
|
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)")
|
||||||
|
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>\"")
|
||||||
|
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: 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")
|
||||||
webHost = flag.String("web-host", "localhost", "Web UI server host")
|
webHost = flag.String("web-host", "localhost", "Web UI server host")
|
||||||
@@ -165,6 +180,35 @@ 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.
|
||||||
|
sender, err := mail.Config{
|
||||||
|
SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile,
|
||||||
|
Dir: *mailDir, From: *mailFrom,
|
||||||
|
}.Build()
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, mail.ErrNoSender):
|
||||||
|
log.Printf("Password recovery disabled (no -smtp-host or -mail-dir)")
|
||||||
|
case err != nil:
|
||||||
|
log.Fatalf("Invalid mail configuration: %v", err)
|
||||||
|
case store == nil:
|
||||||
|
log.Fatalf("Password recovery (-smtp-host or -mail-dir) requires a database (-dsn)")
|
||||||
|
case *publicURL == "":
|
||||||
|
log.Fatalf("Password recovery requires -public-url, the web client address that reset links open")
|
||||||
|
default:
|
||||||
|
if err := svc.SetMailer(sender, *publicURL); err != nil {
|
||||||
|
log.Fatalf("Invalid -public-url: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Password recovery enabled: %s; links open %s", sender.Describe(), *publicURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Start cleanup job for expired users/sessions
|
// Start cleanup job for expired users/sessions
|
||||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||||
cleanupDone := make(chan struct{})
|
cleanupDone := make(chan struct{})
|
||||||
|
|||||||
@@ -18,11 +18,26 @@
|
|||||||
# 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: emailed links open
|
||||||
|
# it, and its host is the Site of exported PGN.
|
||||||
|
# Default: none
|
||||||
|
# chessd_smtp_host: SMTP relay for password-reset email (the mail jail).
|
||||||
|
# Unset leaves password recovery off. Default: none
|
||||||
|
# chessd_smtp_port: Relay port. Default: 25
|
||||||
|
# chessd_smtp_user: Relay user name, when the relay authenticates
|
||||||
|
# this host. Default: none
|
||||||
|
# chessd_smtp_password_file: File holding that user's password, mode 0600.
|
||||||
|
# Default: ${chessd_home}/smtp.secret when it exists
|
||||||
|
# chessd_mail_from: Sender address of server email. Default: none
|
||||||
# chessd_flags: Additional flags passed to the binary. Default: ""
|
# chessd_flags: Additional flags passed to the binary. Default: ""
|
||||||
#
|
#
|
||||||
# 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>"
|
||||||
|
# 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_mail_from="Chess <chess@<site>>"
|
||||||
|
|
||||||
. /etc/rc.subr
|
. /etc/rc.subr
|
||||||
|
|
||||||
@@ -43,6 +58,12 @@ load_rc_config "$name"
|
|||||||
: ${chessd_dsn:="postgres:///chess?host=/tmp"}
|
: ${chessd_dsn:="postgres:///chess?host=/tmp"}
|
||||||
: ${chessd_jwt_key:="${chessd_home}/jwt.key"}
|
: ${chessd_jwt_key:="${chessd_home}/jwt.key"}
|
||||||
: ${chessd_trusted_proxies:=""}
|
: ${chessd_trusted_proxies:=""}
|
||||||
|
: ${chessd_public_url:=""}
|
||||||
|
: ${chessd_smtp_host:=""}
|
||||||
|
: ${chessd_smtp_port:="25"}
|
||||||
|
: ${chessd_smtp_user:=""}
|
||||||
|
: ${chessd_smtp_password_file:=""}
|
||||||
|
: ${chessd_mail_from:=""}
|
||||||
: ${chessd_flags:=""}
|
: ${chessd_flags:=""}
|
||||||
|
|
||||||
command="${chessd_bin}"
|
command="${chessd_bin}"
|
||||||
@@ -85,6 +106,22 @@ 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
|
||||||
|
if [ -n "${chessd_public_url}" ]; then
|
||||||
|
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
|
||||||
|
set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \
|
||||||
|
-mail-from "${chessd_mail_from}"
|
||||||
|
if [ -n "${chessd_smtp_user}" ]; then
|
||||||
|
set -- "$@" -smtp-user "${chessd_smtp_user}"
|
||||||
|
secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}"
|
||||||
|
if [ -f "${secret}" ]; then
|
||||||
|
set -- "$@" -smtp-password-file "${secret}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting ${desc}."
|
echo "Starting ${desc}."
|
||||||
# -r restarts the server 5 seconds after an unexpected exit (for example
|
# -r restarts the server 5 seconds after an unexpected exit (for example
|
||||||
|
|||||||
+114
-4
@@ -116,6 +116,48 @@ Errors: 400 `INVALID_REQUEST` without a password, 401 for a wrong password or
|
|||||||
token, 429 past 5 attempts a minute per IP, 503 `RESOURCE_LIMIT` when password
|
token, 429 past 5 attempts a minute per IP, 503 `RESOURCE_LIMIT` when password
|
||||||
hashing is saturated or `STORAGE_UNAVAILABLE` when storage is down.
|
hashing is saturated or `STORAGE_UNAVAILABLE` when storage is down.
|
||||||
|
|
||||||
|
### Request a Password Reset
|
||||||
|
`POST /auth/recover`
|
||||||
|
|
||||||
|
Emails a one-time reset link to the account's address. The response is the
|
||||||
|
same whether or not the account exists or has an email, so nothing is
|
||||||
|
revealed.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"identifier": "alice"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `identifier` (string, required): Username or email address
|
||||||
|
|
||||||
|
**Response (202):**
|
||||||
|
```json
|
||||||
|
{"message": "If the account has an email address, a reset link has been sent to it."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors: 429 past 3 requests a minute per IP, 503 `NOT_CONFIGURED` when the
|
||||||
|
server has no mail transport (`/health` reports `"mail": "disabled"`), 502
|
||||||
|
when the relay refused the message.
|
||||||
|
|
||||||
|
### Reset a Password
|
||||||
|
`POST /auth/reset`
|
||||||
|
|
||||||
|
Sets a new password with the token from the link (`?reset=<token>` on the web
|
||||||
|
client) and signs the user in; the token works once, for an hour, and every
|
||||||
|
other session of the account ends.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"token": "…", "password": "NewPass123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):** as login (`token`, `userId`, `username`, `email`,
|
||||||
|
`expiresAt`).
|
||||||
|
|
||||||
|
Errors: 400 `INVALID_REQUEST` for a weak password or an invalid, used or
|
||||||
|
expired token (`"invalid or expired reset link"`), 429 past 10 a minute per
|
||||||
|
IP, 503 when storage or password hashing is unavailable.
|
||||||
|
|
||||||
## Game Endpoints
|
## Game Endpoints
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
@@ -129,10 +171,14 @@ Returns server and storage status, and the build the server runs.
|
|||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"time": 1699123456,
|
"time": 1699123456,
|
||||||
"storage": "ok",
|
"storage": "ok",
|
||||||
|
"mail": "disabled",
|
||||||
"version": "v0.14.0-3-g1a2b3c4"
|
"version": "v0.14.0-3-g1a2b3c4"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`mail` is `"ok"` when the server can email password-reset links (the web
|
||||||
|
client then offers **Forgot password?**), otherwise `"disabled"`.
|
||||||
|
|
||||||
`version` is `git describe` of the source the binary was built from (`make`
|
`version` is `git describe` of the source the binary was built from (`make`
|
||||||
sets it), or `dev` for a plain `go build`. Compare it after a deployment: a
|
sets it), or `dev` for a plain `go build`. Compare it after a deployment: a
|
||||||
client newer than the server gets 404 `NOT_FOUND` for endpoints the server
|
client newer than the server gets 404 `NOT_FOUND` for endpoints the server
|
||||||
@@ -180,6 +226,7 @@ Authorization: Bearer <token>
|
|||||||
"turn": "w",
|
"turn": "w",
|
||||||
"state": "ongoing",
|
"state": "ongoing",
|
||||||
"moves": [],
|
"moves": [],
|
||||||
|
"san": [],
|
||||||
"players": {
|
"players": {
|
||||||
"white": {"id": "550e8400-...", "color": 1, "type": 1},
|
"white": {"id": "550e8400-...", "color": 1, "type": 1},
|
||||||
"black": {"id": "ai-player-...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
"black": {"id": "ai-player-...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
||||||
@@ -210,6 +257,9 @@ GET /games/{gameId}?wait=true&moveCount=5
|
|||||||
```
|
```
|
||||||
|
|
||||||
Response includes all game data. Compare `moves` array length to detect changes.
|
Response includes all game data. Compare `moves` array length to detect changes.
|
||||||
|
`moves` holds the UCI line and `san` the same moves in standard algebraic
|
||||||
|
notation, derived by the rules core from the position before each move (an
|
||||||
|
entry is empty only for a move it cannot notate, which the server logs).
|
||||||
|
|
||||||
**Timeout behavior:**
|
**Timeout behavior:**
|
||||||
- Returns current state after 30 seconds even if no changes
|
- Returns current state after 30 seconds even if no changes
|
||||||
@@ -240,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}
|
||||||
@@ -262,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),
|
||||||
@@ -290,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"]
|
||||||
@@ -337,8 +391,10 @@ later first-move slot claim, newest first. Requires
|
|||||||
be combined with `cursor`, and pages shift when games are added.
|
be combined with `cursor`, and pages shift when games are added.
|
||||||
|
|
||||||
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
||||||
or the initial FEN when there is none), result and its `pgnResult` token,
|
or the initial FEN when there is none), result, its `pgnResult` token and
|
||||||
timestamps, players, and move count. `nextCursor` is present only when another
|
`termination` (as in the history; both omitted while ongoing), timestamps,
|
||||||
|
players, 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.
|
||||||
@@ -355,9 +411,11 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
|
|||||||
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||||
"result": "white_wins",
|
"result": "white_wins",
|
||||||
"pgnResult": "1-0",
|
"pgnResult": "1-0",
|
||||||
|
"termination": "checkmate",
|
||||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||||
"moveCount": 7,
|
"moveCount": 7,
|
||||||
|
"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}
|
||||||
@@ -370,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`
|
||||||
|
|
||||||
@@ -550,6 +658,8 @@ Error codes:
|
|||||||
- Registration: 5 requests/minute/IP
|
- Registration: 5 requests/minute/IP
|
||||||
- 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
|
||||||
|
- 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
|
||||||
|
|||||||
+90
-8
@@ -12,13 +12,13 @@ The chess client is an interactive command-line debugging tool for the chess ser
|
|||||||
- Verbose mode for detailed API request/response inspection
|
- Verbose mode for detailed API request/response inspection
|
||||||
- Long-polling support for real-time game updates
|
- Long-polling support for real-time game updates
|
||||||
|
|
||||||
## Replay Foundation
|
## Replay
|
||||||
|
|
||||||
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
||||||
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
||||||
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
|
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
|
||||||
`games` and `pgn` commands use them; step-by-step `replay` controls are still
|
`games`, `pgn` and `replay` commands use them. A replay renders the stored
|
||||||
planned, see [Replay Implementation Tasks](./todo.md).
|
FEN of each ply; the client never computes a move.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
```bash
|
```bash
|
||||||
@@ -44,6 +44,7 @@ The prompt dynamically updates to show context:
|
|||||||
- `chess [username - gameId]` - User in active game
|
- `chess [username - gameId]` - User in active game
|
||||||
- `chess [username - gameId] White` - Shows player color
|
- `chess [username - gameId] White` - Shows player color
|
||||||
- Turn indicator with player type (h=human, c=computer)
|
- Turn indicator with player type (h=human, c=computer)
|
||||||
|
- `chess [...] replay 12/40` - Replaying a stored game at ply 12 of 40
|
||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
@@ -180,19 +181,88 @@ repetition.` or `Black wins by resignation.`; a pending offer is shown as
|
|||||||
`White offers a draw`.
|
`White offers a draw`.
|
||||||
|
|
||||||
#### `games` / `g`
|
#### `games` / `g`
|
||||||
List your stored games, newest first, 20 per page (requires login). The
|
List your stored games, newest first, 20 per page (requires login), numbered
|
||||||
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
|
for `replay <#>` and `games delete <#>`. The result column is the PGN token
|
||||||
|
(`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`
|
||||||
Print a stored game as PGN (SAN movetext, standard tags).
|
Print a stored game as PGN (SAN movetext, standard tags), or save it with
|
||||||
|
`save <path>`. During a replay the default is the replayed game up to the
|
||||||
|
current ply.
|
||||||
```
|
```
|
||||||
chess > pgn # Current game, every ply
|
chess > pgn # Current game, every ply
|
||||||
chess > pgn 10 # Current game, first 10 plies
|
chess > pgn 10 # Current game, first 10 plies
|
||||||
chess > pgn <gameId> [ply] # Another game
|
chess > pgn <gameId> [ply] # Another game
|
||||||
|
chess > pgn save game.pgn # Write instead of print
|
||||||
|
```
|
||||||
|
In the browser build there is no file system: `save` prints the PGN to copy.
|
||||||
|
|
||||||
|
### Replay Commands
|
||||||
|
|
||||||
|
A replay steps through a stored game's history: any game by ID (histories
|
||||||
|
are public to holders of the ID), or one of your own by its number in the
|
||||||
|
last `games` listing. The replay is separate from the live session: the
|
||||||
|
current game, login and poll position are kept, and `new`, `join`, `move`,
|
||||||
|
`computer`, `undo`, `delete`, `poll`, `resign` and `draw` are refused until
|
||||||
|
`live` ends the replay.
|
||||||
|
|
||||||
|
#### `replay`
|
||||||
|
Load a stored game and show its initial position, the players, the result,
|
||||||
|
and the whole move list.
|
||||||
|
```
|
||||||
|
chess > replay 2 # From the last 'games' page
|
||||||
|
chess > replay a1b2c3d4-e5f6-7890-1234-567890abcdef # Any stored game
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `next` / `>`, `prev` / `<`, `first`, `last`, `goto`
|
||||||
|
Move through the line; each prints the board, the ply, and the move that
|
||||||
|
led to it (`2... Nf6`). `next` and `prev` take a count.
|
||||||
|
```
|
||||||
|
chess > next
|
||||||
|
chess > next 4
|
||||||
|
chess > prev
|
||||||
|
chess > goto 12
|
||||||
|
chess > last
|
||||||
|
chess > first
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `auto`
|
||||||
|
Play forward from the current ply to the end of the line, one board per ply,
|
||||||
|
with a delay in milliseconds (default 1000).
|
||||||
|
```
|
||||||
|
chess > auto # one ply a second
|
||||||
|
chess > auto 250
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `fen`
|
||||||
|
Print the FEN of the current position.
|
||||||
|
```
|
||||||
|
chess > fen
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `show` / `h` while replaying
|
||||||
|
Shows the replayed position instead of the live game.
|
||||||
|
|
||||||
|
#### `live`
|
||||||
|
Leave the replay and return to the live session.
|
||||||
|
```
|
||||||
|
chess > live
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
@@ -236,6 +306,13 @@ Exit the client.
|
|||||||
chess > exit
|
chess > exit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Long output
|
||||||
|
|
||||||
|
`help`, `games`, `pgn`, `state`, `show` and `replay` print one screen at a
|
||||||
|
time when their output is taller than the terminal: `-- More (N lines left;
|
||||||
|
Enter continues, q quits) --`. The terminal's size comes from the TTY, or in
|
||||||
|
the browser from xterm.js; output piped to another program is never paged.
|
||||||
|
|
||||||
### Verbose Mode
|
### Verbose Mode
|
||||||
|
|
||||||
Append `-v` flag to any command for detailed output:
|
Append `-v` flag to any command for detailed output:
|
||||||
@@ -285,10 +362,14 @@ ASCII board with colored pieces:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Move History
|
### Move History
|
||||||
Displayed in UCI notation with move numbers:
|
The live game's history is displayed in UCI notation with move numbers:
|
||||||
```
|
```
|
||||||
History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
|
History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
|
||||||
```
|
```
|
||||||
|
A replay lists the stored game in SAN, with the current ply in brackets:
|
||||||
|
```
|
||||||
|
1. e4 c5 2. [Nf3] Nf6 3. Nc3 e6
|
||||||
|
```
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
@@ -319,8 +400,9 @@ History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
|
|||||||
### Limitations
|
### Limitations
|
||||||
- No move validation in client (server validates)
|
- No move validation in client (server validates)
|
||||||
- No algebraic notation input (UCI format only)
|
- No algebraic notation input (UCI format only)
|
||||||
- Single game context at a time
|
- Single game context at a time (plus one replay)
|
||||||
- No persistent authentication across restarts
|
- No persistent authentication across restarts
|
||||||
|
- `auto` runs to the end of the line; it cannot be interrupted
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -131,9 +131,23 @@ sudo chess-db user list
|
|||||||
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||||
```
|
```
|
||||||
|
|
||||||
Other `user` subcommands: `delete`, `set-email`, `set-username`, and
|
Other `user` subcommands: `delete`, `set-email`, `set-username`, `set-hash`
|
||||||
`set-hash` (an existing PHC string). CLI-created accounts are identical to
|
(an existing PHC string), and `reset-link -url <web client>`, which prints a
|
||||||
site registrations, and the CLI ignores the registration cap.
|
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
|
||||||
|
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
|
||||||
|
in the emailed link, one per account, deleted when used, expired, or by the
|
||||||
|
hourly cleanup. The token itself is never stored.
|
||||||
|
|
||||||
Sign a user out everywhere (takes effect on their next request):
|
Sign a user out everywhere (takes effect on their next request):
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| `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`),
|
||||||
|
|||||||
+52
-2
@@ -154,6 +154,10 @@ 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 (`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_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_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
|
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
|
||||||
|
|
||||||
The script runs `daemon(8)` as root, which writes the supervisor pidfile
|
The script runs `daemon(8)` as root, which writes the supervisor pidfile
|
||||||
@@ -174,6 +178,11 @@ Server flags:
|
|||||||
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
||||||
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
||||||
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
|
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
|
||||||
|
| `-smtp-host`, `-smtp-port` | | SMTP relay for password-reset email (default port 25); recovery is off without a transport |
|
||||||
|
| `-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-dir` | | Development: write email to `.eml` files here instead of a relay |
|
||||||
|
| `-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:
|
||||||
@@ -185,8 +194,49 @@ 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|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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jail# su -m chess -c '/home/chess/bin/chess-server db user set-password -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||||
|
```
|
||||||
|
|
||||||
|
which prompts for the new password (or takes `-password`), or with a one-time
|
||||||
|
link to hand to the person, the same link the recovery email carries:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jail# su -m chess -c '/home/chess/bin/chess-server db user reset-link -username <name> -url https://<site>/chess/web-client/ -dsn "postgres:///chess?host=/tmp"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Password Recovery by Email
|
||||||
|
|
||||||
|
With an SMTP relay the server emails a reset link on request (`POST
|
||||||
|
/api/auth/recover`, the **Forgot password?** link in the web client); the link
|
||||||
|
opens the client, which sets the new password and signs the person in. The
|
||||||
|
link works for an hour and once; using it signs the account out everywhere
|
||||||
|
else. Accounts without an email address cannot use it (the CLI commands above
|
||||||
|
can).
|
||||||
|
|
||||||
|
The relay is a separate jail reached over the internal network. Set in
|
||||||
|
`rc.conf`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sysrc chessd_public_url="https://<site>/chess/web-client/"
|
||||||
|
sysrc chessd_smtp_host="<mail jail address>" # chessd_smtp_port, default 25
|
||||||
|
sysrc chessd_mail_from="Chess <chess@<site>>"
|
||||||
|
# only when the relay authenticates this host:
|
||||||
|
sysrc chessd_smtp_user="<user>"
|
||||||
|
(umask 077 && echo '<password>' > /home/chess/smtp.secret) && chown chess:chess /home/chess/smtp.secret
|
||||||
|
service chessd restart
|
||||||
|
```
|
||||||
|
|
||||||
|
The server uses STARTTLS when the relay offers it and sends credentials only
|
||||||
|
over TLS. `GET /chess/health` reports `"mail": "ok"` once recovery is on. For
|
||||||
|
a test without a relay, `-mail-dir <dir>` in `chessd_flags` writes each
|
||||||
|
message to a file instead.
|
||||||
|
|
||||||
## Host nginx and Web Clients
|
## Host nginx and Web Clients
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -38,6 +38,9 @@ go build ./cmd/chess-client-cli
|
|||||||
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
||||||
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
||||||
- `-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
|
||||||
|
- `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending
|
||||||
|
- `-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
|
||||||
@@ -221,6 +224,10 @@ go test ./...
|
|||||||
|
|
||||||
# Concurrency checks for the state/persistence boundary
|
# Concurrency checks for the state/persistence boundary
|
||||||
go test -race ./internal/server/storage ./internal/server/service
|
go test -race ./internal/server/storage ./internal/server/service
|
||||||
|
|
||||||
|
# Web client end-to-end (Playwright): see test/README.md
|
||||||
|
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh # terminal 1
|
||||||
|
CHESS_E2E_MAIL_DIR=/tmp/chess-mail make test-e2e # terminal 2
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -304,11 +311,11 @@ go test -race ./internal/server/storage ./internal/server/service
|
|||||||
- JWT tokens don't support refresh (must re-login after expiry)
|
- JWT tokens don't support refresh (must re-login after expiry)
|
||||||
- User deletion doesn't cascade to games (games keep player and claim IDs)
|
- User deletion doesn't cascade to games (games keep player and claim IDs)
|
||||||
- Games played without a registered player are deleted 24 hours after their last activity
|
- Games played without a registered player are deleted 24 hours after their last activity
|
||||||
- No password recovery mechanism
|
- Password recovery needs an SMTP relay and an email on the account
|
||||||
- No email verification for registration
|
- No email verification for registration
|
||||||
- Fixed worker pool size for engine calculations
|
- Fixed worker pool size for engine calculations
|
||||||
- No push-based game updates (30-second long-polling is used)
|
- No push-based game updates (30-second long-polling is used)
|
||||||
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
||||||
- Database history has no automatic retention policy
|
- Database history has no automatic retention policy
|
||||||
- Replay UI, PGN export, and curated games are planned in [Replay Plan](./todo.md)
|
- Curated games are planned in [Replay Plan](./todo.md)
|
||||||
- REST API only
|
- REST API only
|
||||||
+122
-44
@@ -31,6 +31,8 @@ into phases that each ship and test on their own.
|
|||||||
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
|
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
|
||||||
- [x] Pawn promotion in the web client (piece picker) and a specific API error
|
- [x] Pawn promotion in the web client (piece picker) and a specific API error
|
||||||
for a promotion without its piece.
|
for a promotion without its piece.
|
||||||
|
- [x] Web replay UI (R3) and terminal replay commands (R4), below; `san` on
|
||||||
|
the live game response and `termination` on the listing.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@ into phases that each ship and test on their own.
|
|||||||
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. **Refined (v3 schema):** a resignation or agreed draw between two humans is final, and undo, player changes, and unload respect other users' claims (no takebacks between players). Against the computer a concession can be undone, but the first one stays on record (`concession_*`) and in history and PGN. |
|
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. **Refined (v3 schema):** a resignation or agreed draw between two humans is final, and undo, player changes, and unload respect other users' claims (no takebacks between players). Against the computer a concession can be undone, but the first one stays on record (`concession_*`) and in history and PGN. |
|
||||||
| D8 | Human-vs-human in the web client | **Open.** The web client plays only against the computer; two-player games run through the API and terminal client (`join`, `poll`). A web mode would need join-by-link, a long-poll for the opponent's moves, and an offer indicator on the Draw button (accept by pressing it). |
|
| D8 | Human-vs-human in the web client | **Open.** The web client plays only against the computer; two-player games run through the API and terminal client (`join`, `poll`). A web mode would need join-by-link, a long-poll for the opponent's moves, and an offer indicator on the Draw button (accept by pressing it). |
|
||||||
| D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
|
| D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
|
||||||
|
| D9 | Replay for visitors without an account | **Done.** The history endpoint is public by ID (D2), so the web client keeps a list of the games it created anonymously in `localStorage` (ID, side, level, last position; entries expire with the server's 24-hour retention) and shows them under **On this device** in My games. A signed-in user's list comes from the API. No server change. |
|
||||||
|
| D10 | Live SAN | **Done.** The live game response carries `san` beside `moves`, derived by the rules core from the position before each ply (the shadow check already parses it), so the web client shows SAN without an extra history request and without storage. |
|
||||||
|
| D11 | Replay history | **Done.** Views push browser history entries (`?games`, `?replay=<id>&ply=<n>`, or both); stepping through a replay replaces the entry so back and forward move between the live view, the list and the replay rather than ply by ply. |
|
||||||
|
|
||||||
## Phase R1 — Notation Core (server) — done
|
## Phase R1 — Notation Core (server) — done
|
||||||
|
|
||||||
@@ -88,29 +93,43 @@ Test gate:
|
|||||||
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
||||||
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
|
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
|
||||||
|
|
||||||
## Phase R3 — Web Replay UI
|
## Phase R3 — Web Replay UI — done
|
||||||
|
|
||||||
Plain JavaScript in the embedded client, no framework.
|
Plain JavaScript in the embedded client, no framework. The client was
|
||||||
|
restructured around the replay: a top bar (brand, server and storage dots,
|
||||||
|
**My games**, account), the board, and a side panel with a live pane and a
|
||||||
|
replay pane; the board colours and piece glyphs are unchanged.
|
||||||
|
|
||||||
- [ ] **My games** button beside the account indicator (authenticated only)
|
- [x] **My games** button in the top bar opening a drawer: mini-board from
|
||||||
opening a panel: mini-board from `finalFen`, result, date, opponent or
|
`finalFen`, players (the viewer's side as "You"), result and termination,
|
||||||
engine level, move count; loading, empty, error, and "load more" states.
|
date, move count; status and colour filters; loading, empty, error (with
|
||||||
- [ ] Replay mode entered from the panel or a deep link
|
retry), and "load more" (cursor) states. Signed-out visitors see the games
|
||||||
`?replay=<gameId>&ply=<n>`; `history.pushState` so browser back/forward
|
played on this device (D9) and a sign-in note.
|
||||||
restores the list and the ply.
|
- [x] Replay mode entered from the drawer, the live pane's **Replay** button,
|
||||||
- [ ] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`;
|
or a deep link `?replay=<gameId>&ply=<n>`; `history.pushState` per view
|
||||||
clickable SAN move list; autoplay with speed selector and pause; board flip;
|
(D11), so browser back/forward restores the list and the ply.
|
||||||
current ply announced through an `aria-live` region.
|
- [x] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`,
|
||||||
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
`Space` (autoplay), `F` (flip), `Esc` (leave); clickable SAN move list with
|
||||||
computes moves.
|
the current ply marked; autoplay with a speed selector and pause; board
|
||||||
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
flip, defaulting to the viewer's side; current ply announced through an
|
||||||
from R2 as a `Blob`, saved through a temporary `<a download>`). The live
|
`aria-live` region.
|
||||||
view's "Copy PGN" button copies UCI moves today; point it at the PGN
|
- [x] Position rendering assigns the stored FEN for the ply; the browser never
|
||||||
endpoint too.
|
computes moves. The mated king and the last move are marked as in live play.
|
||||||
- [ ] Show SAN in the live move list (from the history, or a `san` field on
|
- [x] Export at the current ply: copy FEN, copy PGN (`?ply=N`, or the full
|
||||||
the live game response).
|
export at the last ply), download PGN (fetched as a `Blob`, saved through a
|
||||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
temporary `<a download>`; the host's iframe sandbox needs
|
||||||
and player-configuration controls; **Back to live game** restores them.
|
`allow-downloads`), and copy a link to the position. The live view's "Copy
|
||||||
|
PGN" copies the stored game's PGN, falling back to the SAN line and FEN
|
||||||
|
when storage is off; "Copy FEN" beside it.
|
||||||
|
- [x] SAN in the live move list from the `san` field on the live game
|
||||||
|
response (D10).
|
||||||
|
- [x] While replaying: live long-polling stops, the live pane (move, undo,
|
||||||
|
new game, draw, resign) hides and square clicks are ignored; **Back to
|
||||||
|
live game** (or **Close replay** without one) restores them and resumes
|
||||||
|
polling if the computer was thinking.
|
||||||
|
- [x] History responses are revalidated with `If-None-Match`; `ETag` and
|
||||||
|
`Content-Disposition` are exposed over CORS for the development setup,
|
||||||
|
where the web client and the API are on different ports.
|
||||||
|
|
||||||
Constraints from the host's security headers:
|
Constraints from the host's security headers:
|
||||||
|
|
||||||
@@ -122,35 +141,93 @@ 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:
|
||||||
|
|
||||||
- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a
|
- [x] Playwright (Chromium) end-to-end against server and PostgreSQL
|
||||||
short game, open My games, step backward and forward, autoplay to the end,
|
(`test/e2e`, run by the `e2e` CI job): play a short game, open My games,
|
||||||
and compare exported FEN/PGN with the API.
|
step backward and forward with buttons and keys, autoplay to the end,
|
||||||
- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion
|
compare the exported FEN and PGN with the API, download, copy a link,
|
||||||
disables autoplay animation.
|
browser back through replay and list; a deep link to a checkmate; the
|
||||||
- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`.
|
device list for an anonymous visitor; 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
|
||||||
|
name, the move list is a list of buttons, the current ply has
|
||||||
|
`aria-current` and is announced; `prefers-reduced-motion` disables the
|
||||||
|
client's animations and transitions (autoplay itself is user-initiated and
|
||||||
|
stays).
|
||||||
|
- [x] Deep link: the client reads `?replay=` from its own URL, so it works
|
||||||
|
wherever the document is published (`/chess/web-client/` on lixen.com,
|
||||||
|
with the API at `/chess`); the old `/projects/chess/` path redirects there.
|
||||||
|
|
||||||
## Phase R4 — CLI and WASM Replay (optional)
|
## Phase R4 — CLI and WASM Replay — done
|
||||||
|
|
||||||
- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`.
|
- [x] `games` (list with cursor paging, numbered) and `pgn [gameId] [ply]`.
|
||||||
- [ ] `replay <gameId|index>`, `next`, `prev`, `first`, `last`, `goto <ply>`,
|
- [x] `replay <gameId|index>` (index into the last `games` page), `next`
|
||||||
`auto <ms>`, `fen`, `pgn save <path>`.
|
`prev` (`>` `<`, with a count), `first`, `last`, `goto <ply>`, `auto [ms]`,
|
||||||
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
`fen`, `pgn [save <path>]` (the replayed game up to the current ply; in
|
||||||
or configuration while replaying.
|
the browser build the PGN is printed instead of saved), `live` to leave.
|
||||||
|
- [x] Replay state is separate from the live session: `new`, `join`, `move`,
|
||||||
|
`computer`, `undo`, `delete`, `poll`, `resign` and `draw` are refused
|
||||||
|
while replaying, `show` shows the replayed position, and the prompt reads
|
||||||
|
`replay <ply>/<plies>`; `live` returns to the game as it was.
|
||||||
|
|
||||||
## Phase R5 — Curated Archive (later)
|
Follow-up polish (done): the phone layout is full-width rows under the board
|
||||||
|
(`svh` units, so the browser's bars never cover the controls; a replay
|
||||||
|
reserves more height than live play), the turn strip says whose move it is in
|
||||||
|
two words with the dot showing the side, the sign-in dialog keeps the focus in
|
||||||
|
the password field after a wrong password, the brand mark is the site's
|
||||||
|
bishop, and the terminal client pages long output (help, games, pgn, state,
|
||||||
|
show, replay) one screen at a time, natively and in the browser.
|
||||||
|
|
||||||
- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site,
|
Later for the replay clients:
|
||||||
date, round, names, Elo, ECO, source URL, license, attribution), publication
|
|
||||||
state, featured order; no user ownership.
|
- [ ] Web: an "Analysis" link on a replay (chess.com-style evaluation needs
|
||||||
- [ ] `chess-server db archive import <pgn|dir>` using the R1 SAN decoder:
|
an engine in the browser or a server endpoint; none exists).
|
||||||
validate every move, import each game in one transaction, idempotent by
|
- [ ] CLI: `auto` cannot be interrupted before the end of the line.
|
||||||
content hash, dry-run and per-file summary.
|
|
||||||
|
## Accounts
|
||||||
|
|
||||||
|
- [x] Password recovery by email: `POST /api/auth/recover` sends a one-time
|
||||||
|
link (an hour, SHA-256 of the token stored in `password_resets`, schema
|
||||||
|
v4) to the account's address through an SMTP relay (`-smtp-host`, with
|
||||||
|
optional credentials over TLS) or, for development, a directory of `.eml`
|
||||||
|
files (`-mail-dir`); `POST /api/auth/reset` sets the password, ends the
|
||||||
|
account's other sessions and signs the person in; the web client offers
|
||||||
|
**Forgot password?** when `/health` reports `"mail": "ok"` and opens the
|
||||||
|
password form from `?reset=<token>`. `db user reset-link` prints the same
|
||||||
|
link for an administrator; `db user set-password` resets without one.
|
||||||
|
- [ ] Deliver from the chess jail through the mail jail (devops): relay
|
||||||
|
address, sender, and `chessd_smtp_*` in `rc.conf` (deployment.md).
|
||||||
|
- [ ] Email verification at registration and on `set-email`; today an
|
||||||
|
address is taken as typed, so a typo sends nobody a link.
|
||||||
|
|
||||||
|
## Phase R5 — Imported and Curated Games
|
||||||
|
|
||||||
|
Done, per user: a signed-in user imports one PGN game (`POST
|
||||||
|
/api/games/import`, **Import PGN** in My games, `import <file>` in the CLI,
|
||||||
|
`db game import` for an administrator) into their own list. Schema v5 adds
|
||||||
|
`imported_by`, `event`, `site`, `round`; the strict parser
|
||||||
|
(`chess.ParsePGN`, fuzzed) refuses anything but one well-formed game, every
|
||||||
|
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.
|
||||||
@@ -162,7 +239,8 @@ Test gate:
|
|||||||
- [ ] 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||||
github.com/go-playground/locales v0.14.2 // indirect
|
github.com/go-playground/locales v0.14.2 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.2 // indirect
|
github.com/go-playground/universal-translator v0.18.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
@@ -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.0 // 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.75.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
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
|
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
|
||||||
@@ -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,10 +39,10 @@ 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.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||||
github.com/philhofer/fwd v1.2.0/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=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -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.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA=
|
github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
|
||||||
github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s=
|
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=
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
|
|||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
statusColor = display.Red
|
statusColor = display.Red
|
||||||
}
|
}
|
||||||
fmt.Printf("%s[%d %s]%s\n", statusColor, resp.StatusCode, http.StatusText(resp.StatusCode), display.Reset)
|
display.Printf("%s[%d %s]%s\n", statusColor, resp.StatusCode, http.StatusText(resp.StatusCode), display.Reset)
|
||||||
|
|
||||||
// Display response body if verbose
|
// Display response body if verbose
|
||||||
if c.Verbose && len(respBody) > 0 {
|
if c.Verbose && len(respBody) > 0 {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type GameResponse struct {
|
|||||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||||
Concession *Concession `json:"concession,omitempty"`
|
Concession *Concession `json:"concession,omitempty"`
|
||||||
Moves []string `json:"moves"`
|
Moves []string `json:"moves"`
|
||||||
|
SAN []string `json:"san"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -129,6 +130,11 @@ type GameHistoryResponse struct {
|
|||||||
Termination string `json:"termination,omitempty"`
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
|
Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins")
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
@@ -148,12 +154,30 @@ type GameSummary struct {
|
|||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
PGNResult string `json:"pgnResult"`
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
|
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"`
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func registerHandler(s *session.Session, args []string) error {
|
|||||||
c.SetToken(resp.Token)
|
c.SetToken(resp.Token)
|
||||||
|
|
||||||
display.Println(display.Green, "Registered successfully")
|
display.Println(display.Green, "Registered successfully")
|
||||||
fmt.Printf("User ID: %s\n", resp.UserID)
|
display.Printf("User ID: %s\n", resp.UserID)
|
||||||
fmt.Printf("Username: %s\n", resp.Username)
|
display.Printf("Username: %s\n", resp.Username)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -111,8 +111,8 @@ func loginHandler(s *session.Session, args []string) error {
|
|||||||
c.SetToken(resp.Token)
|
c.SetToken(resp.Token)
|
||||||
|
|
||||||
display.Println(display.Green, "Logged in successfully")
|
display.Println(display.Green, "Logged in successfully")
|
||||||
fmt.Printf("User ID: %s\n", resp.UserID)
|
display.Printf("User ID: %s\n", resp.UserID)
|
||||||
fmt.Printf("Username: %s\n", resp.Username)
|
display.Printf("Username: %s\n", resp.Username)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -151,14 +151,14 @@ func whoamiHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
display.Println(display.Cyan, "Current User:")
|
display.Println(display.Cyan, "Current User:")
|
||||||
fmt.Printf(" User ID: %s\n", user.UserID)
|
display.Printf(" User ID: %s\n", user.UserID)
|
||||||
fmt.Printf(" Username: %s\n", user.Username)
|
display.Printf(" Username: %s\n", user.Username)
|
||||||
if user.Email != "" {
|
if user.Email != "" {
|
||||||
fmt.Printf(" Email: %s\n", user.Email)
|
display.Printf(" Email: %s\n", user.Email)
|
||||||
}
|
}
|
||||||
fmt.Printf(" Created: %s\n", user.CreatedAt.Format("2006-01-02 15:04:05"))
|
display.Printf(" Created: %s\n", user.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||||
if user.LastLogin != nil {
|
if user.LastLogin != nil {
|
||||||
fmt.Printf(" Last Login: %s\n", user.LastLogin.Format("2006-01-02 15:04:05"))
|
display.Printf(" Last Login: %s\n", user.LastLogin.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -172,7 +172,7 @@ func setUserHandler(s *session.Session, args []string) error {
|
|||||||
userID := args[0]
|
userID := args[0]
|
||||||
s.SetCurrentUser(userID)
|
s.SetCurrentUser(userID)
|
||||||
display.Println(display.Cyan, "User ID set to: %s", userID)
|
display.Println(display.Cyan, "User ID set to: %s", userID)
|
||||||
fmt.Println("Note: This doesn't authenticate, just sets the ID for display")
|
display.Line("Note: This doesn't authenticate, just sets the ID for display")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -44,12 +44,12 @@ func healthHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
display.Println(display.Cyan, "Server Health:")
|
display.Println(display.Cyan, "Server Health:")
|
||||||
fmt.Printf(" Status: %s\n", resp.Status)
|
display.Printf(" Status: %s\n", resp.Status)
|
||||||
// Convert Unix timestamp to readable time
|
// Convert Unix timestamp to readable time
|
||||||
t := time.Unix(resp.Time, 0)
|
t := time.Unix(resp.Time, 0)
|
||||||
fmt.Printf(" Time: %s\n", t.Format("2006-01-02 15:04:05"))
|
display.Printf(" Time: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||||
if resp.Storage != "" {
|
if resp.Storage != "" {
|
||||||
fmt.Printf(" Storage: %s\n", resp.Storage)
|
display.Printf(" Storage: %s\n", resp.Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -57,7 +57,7 @@ func healthHandler(s *session.Session, args []string) error {
|
|||||||
|
|
||||||
func urlHandler(s *session.Session, args []string) error {
|
func urlHandler(s *session.Session, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Printf("Current API URL: %s\n", s.GetAPIBaseURL())
|
display.Printf("Current API URL: %s\n", s.GetAPIBaseURL())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-28
@@ -59,6 +59,7 @@ func (r *Registry) registerGameCommands() {
|
|||||||
Description: "Show board and game state",
|
Description: "Show board and game state",
|
||||||
Usage: "show",
|
Usage: "show",
|
||||||
Handler: showBoardHandler,
|
Handler: showBoardHandler,
|
||||||
|
Paged: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Register(&Command{
|
r.Register(&Command{
|
||||||
@@ -67,6 +68,7 @@ func (r *Registry) registerGameCommands() {
|
|||||||
Description: "Show raw game JSON",
|
Description: "Show raw game JSON",
|
||||||
Usage: "state",
|
Usage: "state",
|
||||||
Handler: gameStateHandler,
|
Handler: gameStateHandler,
|
||||||
|
Paged: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Register(&Command{
|
r.Register(&Command{
|
||||||
@@ -96,17 +98,27 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Register(&Command{
|
r.Register(&Command{
|
||||||
Name: "pgn",
|
Name: "pgn",
|
||||||
ShortName: "",
|
ShortName: "",
|
||||||
Description: "Print a stored game as PGN",
|
Description: "Print or save a stored game as PGN",
|
||||||
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
|
Usage: "pgn [gameId] [ply] [save <path>] (default: current or replayed game)",
|
||||||
Handler: pgnHandler,
|
Handler: pgnHandler,
|
||||||
|
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{
|
||||||
@@ -259,8 +271,8 @@ func joinGameHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%sJoined game: %s%s\n", display.Green, gameID, display.Reset)
|
display.Printf("%sJoined game: %s%s\n", display.Green, gameID, display.Reset)
|
||||||
fmt.Printf("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves))
|
display.Printf("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -368,9 +380,9 @@ func computerMoveHandler(s *session.Session, args []string) error {
|
|||||||
if final.LastMove != nil && len(final.Moves) > baselineMoves {
|
if final.LastMove != nil && len(final.Moves) > baselineMoves {
|
||||||
display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move)
|
display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move)
|
||||||
if final.LastMove.Depth > 0 {
|
if final.LastMove.Depth > 0 {
|
||||||
fmt.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score)
|
display.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
display.Line()
|
||||||
}
|
}
|
||||||
|
|
||||||
printOutcome(final)
|
printOutcome(final)
|
||||||
@@ -520,6 +532,9 @@ func undoHandler(s *session.Session, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showBoardHandler(s *session.Session, args []string) error {
|
func showBoardHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay != nil {
|
||||||
|
return showReplayPosition(s)
|
||||||
|
}
|
||||||
gameID := s.GetCurrentGame()
|
gameID := s.GetCurrentGame()
|
||||||
if gameID == "" {
|
if gameID == "" {
|
||||||
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
||||||
@@ -543,28 +558,28 @@ func showBoardHandler(s *session.Session, args []string) error {
|
|||||||
s.SetGameState(game)
|
s.SetGameState(game)
|
||||||
|
|
||||||
// Display board with colors
|
// Display board with colors
|
||||||
fmt.Println()
|
display.Line()
|
||||||
display.RenderBoard(board.Board)
|
display.RenderBoard(board.Board)
|
||||||
|
|
||||||
// Display game info
|
// Display game info
|
||||||
fmt.Printf("\nFEN: %s\n", game.FEN)
|
display.Printf("\nFEN: %s\n", game.FEN)
|
||||||
fmt.Printf("Turn: %s | State: %s | Moves: %d\n",
|
display.Printf("Turn: %s | State: %s | Moves: %d\n",
|
||||||
display.ColorForTurn(game.Turn), game.State, len(game.Moves))
|
display.ColorForTurn(game.Turn), game.State, len(game.Moves))
|
||||||
|
|
||||||
// Display move history
|
// Display move history
|
||||||
if len(game.Moves) > 0 {
|
if len(game.Moves) > 0 {
|
||||||
fmt.Printf("\nHistory: ")
|
display.Printf("\nHistory: ")
|
||||||
for i, move := range game.Moves {
|
for i, move := range game.Moves {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
fmt.Print(" ")
|
display.Write(" ")
|
||||||
}
|
}
|
||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
fmt.Printf("%d.%s", (i/2)+1, move)
|
display.Printf("%d.%s", (i/2)+1, move)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s", move)
|
display.Printf(" %s", move)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
display.Line()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display last move info
|
// Display last move info
|
||||||
@@ -573,11 +588,11 @@ func showBoardHandler(s *session.Session, args []string) error {
|
|||||||
if game.LastMove.PlayerColor == "b" {
|
if game.LastMove.PlayerColor == "b" {
|
||||||
color = "Black"
|
color = "Black"
|
||||||
}
|
}
|
||||||
fmt.Printf("Last move: %s by %s", game.LastMove.Move, color)
|
display.Printf("Last move: %s by %s", game.LastMove.Move, color)
|
||||||
if game.LastMove.Depth > 0 {
|
if game.LastMove.Depth > 0 {
|
||||||
fmt.Printf(" (depth %d, score %d)", game.LastMove.Depth, game.LastMove.Score)
|
display.Printf(" (depth %d, score %d)", game.LastMove.Depth, game.LastMove.Score)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
display.Line()
|
||||||
}
|
}
|
||||||
printOutcome(game)
|
printOutcome(game)
|
||||||
printConcession(game)
|
printConcession(game)
|
||||||
@@ -627,7 +642,7 @@ func deleteGameHandler(s *session.Session, args []string) error {
|
|||||||
s.SetLastMoveCount(0)
|
s.SetLastMoveCount(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset)
|
display.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +673,7 @@ func pollHandler(s *session.Session, args []string) error {
|
|||||||
case len(resp.Moves) > moveCount:
|
case len(resp.Moves) > moveCount:
|
||||||
display.Println(display.Green, "Game updated! New moves detected")
|
display.Println(display.Green, "Game updated! New moves detected")
|
||||||
if resp.LastMove != nil {
|
if resp.LastMove != nil {
|
||||||
fmt.Printf("Last move: %s\n", resp.LastMove.Move)
|
display.Printf("Last move: %s\n", resp.LastMove.Move)
|
||||||
}
|
}
|
||||||
case len(resp.Moves) < moveCount:
|
case len(resp.Moves) < moveCount:
|
||||||
display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves))
|
display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves))
|
||||||
@@ -679,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 == "" {
|
||||||
@@ -692,23 +710,81 @@ func gamesHandler(s *session.Session, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.GamesCursor = resp.NextCursor
|
s.GamesCursor = resp.NextCursor
|
||||||
|
s.GamesPage = resp.Games
|
||||||
if len(resp.Games) == 0 {
|
if len(resp.Games) == 0 {
|
||||||
display.Println(display.Yellow, "No stored games")
|
display.Println(display.Yellow, "No stored games")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
display.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||||
for _, g := range resp.Games {
|
for i, g := range resp.Games {
|
||||||
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
|
display.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID,
|
||||||
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
||||||
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
display.Println(display.Cyan, "\nReplay one: replay <#> or replay <gameId>")
|
||||||
if resp.NextCursor != "" {
|
if resp.NextCursor != "" {
|
||||||
display.Println(display.Cyan, "\nMore games: games more")
|
display.Println(display.Cyan, "More games: games more")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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:
|
||||||
@@ -720,9 +796,21 @@ func playerLabel(p api.PlayerInfo) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pgnHandler prints the stored game as PGN: the current game unless an ID is
|
// pgnHandler prints the stored game as PGN: the current game unless an ID is
|
||||||
// given, and every ply unless a count is given.
|
// given, and every ply unless a count is given. During a replay the default
|
||||||
|
// is the replayed game up to the current ply. "save <path>" writes the
|
||||||
|
// export to a file instead of printing it.
|
||||||
func pgnHandler(s *session.Session, args []string) error {
|
func pgnHandler(s *session.Session, args []string) error {
|
||||||
|
path, args, err := pgnSaveArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
gameID, ply := s.CurrentGame, -1
|
gameID, ply := s.CurrentGame, -1
|
||||||
|
if s.Replay != nil {
|
||||||
|
gameID = s.Replay.History.GameID
|
||||||
|
if !s.Replay.AtEnd() {
|
||||||
|
ply = s.Replay.Ply
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
||||||
ply = n
|
ply = n
|
||||||
@@ -737,7 +825,14 @@ func pgnHandler(s *session.Session, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
if path != "" {
|
||||||
fmt.Print(text)
|
if err := savePGN(path, trimPGN(text)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
display.Line()
|
||||||
|
display.Write(text)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
package command
|
package command
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/lixenwraith/chess/internal/client/display"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readPassword(prompt string) (string, error) {
|
func readPassword(prompt string) (string, error) {
|
||||||
fmt.Print(prompt)
|
display.Write(prompt)
|
||||||
bytePassword, err := term.ReadPassword(0) // 0 is stdin
|
bytePassword, err := term.ReadPassword(0) // 0 is stdin
|
||||||
fmt.Println()
|
display.Line()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package command
|
package command
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ type Command struct {
|
|||||||
Description string
|
Description string
|
||||||
Usage string
|
Usage string
|
||||||
Handler func(*session.Session, []string) error
|
Handler func(*session.Session, []string) error
|
||||||
|
// Paged commands print at length and read nothing: their output is
|
||||||
|
// shown one screen at a time (display.Page).
|
||||||
|
Paged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
@@ -32,6 +36,7 @@ func NewRegistry(s *session.Session) *Registry {
|
|||||||
|
|
||||||
// Register all commands
|
// Register all commands
|
||||||
r.registerGameCommands()
|
r.registerGameCommands()
|
||||||
|
r.registerReplayCommands()
|
||||||
r.registerAuthCommands()
|
r.registerAuthCommands()
|
||||||
r.registerDebugCommands()
|
r.registerDebugCommands()
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ func NewRegistry(s *session.Session) *Registry {
|
|||||||
Description: "Show available commands",
|
Description: "Show available commands",
|
||||||
Usage: "help [command]",
|
Usage: "help [command]",
|
||||||
Handler: r.helpHandler,
|
Handler: r.helpHandler,
|
||||||
|
Paged: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Exit command (handled in main loop, but registered for help display)
|
// Exit command (handled in main loop, but registered for help display)
|
||||||
@@ -78,12 +84,28 @@ func (r *Registry) Execute(input string) {
|
|||||||
display.Println(display.Reset, "Type 'help' for available commands")
|
display.Println(display.Reset, "Type 'help' for available commands")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.session.Replay != nil && liveOnly[cmd.Name] {
|
||||||
|
display.Println(display.Red, "'%s' is not available while replaying; 'live' returns to the live session", cmd.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set verbose mode in client if session supports it
|
// Set verbose mode in client if session supports it
|
||||||
if cl, ok := r.session.GetClient().(*api.Client); ok {
|
if cl, ok := r.session.GetClient().(*api.Client); ok {
|
||||||
cl.SetVerbose(r.session.IsVerbose())
|
cl.SetVerbose(r.session.IsVerbose())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Paged && r.session.ReadLine != nil {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
previous := display.Out
|
||||||
|
display.Out = &buf
|
||||||
|
err := cmd.Handler(r.session, args)
|
||||||
|
display.Out = previous
|
||||||
|
display.Page(buf.String(), r.session.ReadLine)
|
||||||
|
if err != nil {
|
||||||
|
display.Println(display.Red, "Error: %s", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := cmd.Handler(r.session, args); err != nil {
|
if err := cmd.Handler(r.session, args); err != nil {
|
||||||
display.Println(display.Red, "Error: %s", err.Error())
|
display.Println(display.Red, "Error: %s", err.Error())
|
||||||
}
|
}
|
||||||
@@ -96,13 +118,13 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("unknown command: %s", args[0])
|
return fmt.Errorf("unknown command: %s", args[0])
|
||||||
}
|
}
|
||||||
fmt.Println()
|
display.Line()
|
||||||
display.Print(display.Cyan, cmd.Name)
|
display.Print(display.Cyan, cmd.Name)
|
||||||
display.Println(display.Reset, " - %s", cmd.Description)
|
display.Println(display.Reset, " - %s", cmd.Description)
|
||||||
if cmd.ShortName != "" {
|
if cmd.ShortName != "" {
|
||||||
display.Println(display.Cyan, "Short form: %s", cmd.ShortName)
|
display.Println(display.Cyan, "Short form: %s", cmd.ShortName)
|
||||||
}
|
}
|
||||||
fmt.Printf("Usage: %s\n", cmd.Usage)
|
display.Printf("Usage: %s\n", cmd.Usage)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +152,19 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
{"draw", "", ""},
|
{"draw", "", ""},
|
||||||
{"games", "g", ""},
|
{"games", "g", ""},
|
||||||
{"pgn", "", ""},
|
{"pgn", "", ""},
|
||||||
|
{"import", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
replayCommands := []cmdInfo{
|
||||||
|
{"replay", "", ""},
|
||||||
|
{"next", ">", ""},
|
||||||
|
{"prev", "<", ""},
|
||||||
|
{"first", "", ""},
|
||||||
|
{"last", "", ""},
|
||||||
|
{"goto", "", ""},
|
||||||
|
{"auto", "", ""},
|
||||||
|
{"fen", "", ""},
|
||||||
|
{"live", "", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
authCommands := []cmdInfo{
|
authCommands := []cmdInfo{
|
||||||
@@ -156,18 +191,20 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
if info.shortName != "" {
|
if info.shortName != "" {
|
||||||
shortPart = fmt.Sprintf("[%s%s%s] ", display.Cyan, info.shortName, display.Reset)
|
shortPart = fmt.Sprintf("[%s%s%s] ", display.Cyan, info.shortName, display.Reset)
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s%-10s %s\n", shortPart, cmd.Name, cmd.Description)
|
display.Printf(" %s%-10s %s\n", shortPart, cmd.Name, cmd.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
display.Line()
|
||||||
}
|
}
|
||||||
|
|
||||||
printCommandGroup("Game Commands", gameCommands)
|
printCommandGroup("Game Commands", gameCommands)
|
||||||
|
printCommandGroup("Replay Commands", replayCommands)
|
||||||
printCommandGroup("Auth Commands", authCommands)
|
printCommandGroup("Auth Commands", authCommands)
|
||||||
printCommandGroup("Utility Commands", utilCommands)
|
printCommandGroup("Utility Commands", utilCommands)
|
||||||
|
|
||||||
display.Println(display.Reset, "Type 'help <command>' for detailed usage")
|
display.Println(display.Reset, "Type 'help <command>' for detailed usage")
|
||||||
display.Println(display.Reset, "Add '-v' to any command for verbose output\n")
|
display.Println(display.Reset, "Add '-v' to any command for verbose output")
|
||||||
|
display.Println(display.Reset, "Long output pauses after each screen: Enter continues, q stops\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/client/display"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/replay"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Replay commands step through a stored game from its history. The replay
|
||||||
|
// state lives beside the live session, which keeps its game, user and poll
|
||||||
|
// position; live commands that would change the game are refused until
|
||||||
|
// 'live' ends the replay (see liveOnly).
|
||||||
|
func (r *Registry) registerReplayCommands() {
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "replay",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Step through a stored game",
|
||||||
|
Usage: "replay <gameId|index> (index from the last 'games' listing)",
|
||||||
|
Handler: replayHandler,
|
||||||
|
Paged: true,
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "next",
|
||||||
|
ShortName: ">",
|
||||||
|
Description: "Replay: next ply",
|
||||||
|
Usage: "next [count]",
|
||||||
|
Handler: stepHandler(1),
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "prev",
|
||||||
|
ShortName: "<",
|
||||||
|
Description: "Replay: previous ply",
|
||||||
|
Usage: "prev [count]",
|
||||||
|
Handler: stepHandler(-1),
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "first",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Replay: initial position",
|
||||||
|
Usage: "first",
|
||||||
|
Handler: func(s *session.Session, args []string) error {
|
||||||
|
return gotoHandler(s, []string{"0"})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "last",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Replay: final position",
|
||||||
|
Usage: "last",
|
||||||
|
Handler: func(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return errNotReplaying
|
||||||
|
}
|
||||||
|
return gotoHandler(s, []string{strconv.Itoa(s.Replay.Len())})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "goto",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Replay: show the position after N plies",
|
||||||
|
Usage: "goto <ply>",
|
||||||
|
Handler: gotoHandler,
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "auto",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Replay: play forward to the end",
|
||||||
|
Usage: "auto [ms] (delay per ply, default 1000)",
|
||||||
|
Handler: autoHandler,
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "fen",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Replay: print the FEN of the current position",
|
||||||
|
Usage: "fen",
|
||||||
|
Handler: fenHandler,
|
||||||
|
})
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "live",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Leave the replay and return to the live session",
|
||||||
|
Usage: "live",
|
||||||
|
Handler: liveHandler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNotReplaying = fmt.Errorf("no replay in progress; use 'replay <gameId|index>'")
|
||||||
|
|
||||||
|
// liveOnly names the commands a replay refuses: they would change the live
|
||||||
|
// game or poll it, while a replay only reads a stored one.
|
||||||
|
var liveOnly = map[string]bool{
|
||||||
|
"new": true, "join": true, "move": true, "computer": true, "undo": true,
|
||||||
|
"delete": true, "poll": true, "resign": true, "draw": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayHandler loads a stored game by ID, or by its position in the last
|
||||||
|
// 'games' listing, and shows its initial position.
|
||||||
|
func replayHandler(s *session.Session, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("usage: replay <gameId|index>")
|
||||||
|
}
|
||||||
|
gameID := args[0]
|
||||||
|
if index, err := strconv.Atoi(gameID); err == nil {
|
||||||
|
if index < 1 || index > len(s.GamesPage) {
|
||||||
|
if len(s.GamesPage) == 0 {
|
||||||
|
return fmt.Errorf("no listing to index; run 'games' first or give a game ID")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("index %d is outside the last listing (1-%d)", index, len(s.GamesPage))
|
||||||
|
}
|
||||||
|
gameID = s.GamesPage[index-1].GameID
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := s.Client.GetGameHistory(gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Replay = replay.New(history)
|
||||||
|
|
||||||
|
white, black := s.Replay.Players()
|
||||||
|
display.Println(display.Cyan, "\nReplaying %s", history.GameID)
|
||||||
|
display.Printf("%s vs %s · %s · %d plies · started %s\n",
|
||||||
|
display.C(display.Blue, white), display.C(display.Red, black), s.Replay.Result(), s.Replay.Len(),
|
||||||
|
history.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||||
|
if c := history.Concession; c != nil {
|
||||||
|
what := "a draw was agreed"
|
||||||
|
switch c.Result {
|
||||||
|
case "white_wins":
|
||||||
|
what = "Black resigned"
|
||||||
|
case "black_wins":
|
||||||
|
what = "White resigned"
|
||||||
|
}
|
||||||
|
display.Println(display.Yellow, "%s at ply %d; play continued.", what, c.Ply)
|
||||||
|
}
|
||||||
|
display.Line()
|
||||||
|
display.Line(s.Replay.Movetext(80))
|
||||||
|
display.Println(display.Reset, "\nnext, prev, first, last, goto <ply>, auto [ms], fen, pgn [save <path>], live")
|
||||||
|
return showReplayPosition(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepHandler(direction int) func(*session.Session, []string) error {
|
||||||
|
return func(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return errNotReplaying
|
||||||
|
}
|
||||||
|
count := 1
|
||||||
|
if len(args) > 0 {
|
||||||
|
n, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return fmt.Errorf("invalid count: %s", args[0])
|
||||||
|
}
|
||||||
|
count = n
|
||||||
|
}
|
||||||
|
before := s.Replay.Ply
|
||||||
|
s.Replay.Goto(before + direction*count)
|
||||||
|
if s.Replay.Ply == before {
|
||||||
|
if direction > 0 {
|
||||||
|
display.Println(display.Yellow, "At the end of the game")
|
||||||
|
} else {
|
||||||
|
display.Println(display.Yellow, "At the initial position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return showReplayPosition(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gotoHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return errNotReplaying
|
||||||
|
}
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("usage: goto <ply> (0-%d)", s.Replay.Len())
|
||||||
|
}
|
||||||
|
ply, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid ply: %s", args[0])
|
||||||
|
}
|
||||||
|
if ply < 0 || ply > s.Replay.Len() {
|
||||||
|
return fmt.Errorf("ply %d is outside 0-%d", ply, s.Replay.Len())
|
||||||
|
}
|
||||||
|
s.Replay.Goto(ply)
|
||||||
|
return showReplayPosition(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoHandler plays forward from the current ply to the end, one board per
|
||||||
|
// ply, pausing between them.
|
||||||
|
func autoHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return errNotReplaying
|
||||||
|
}
|
||||||
|
delay := 1000
|
||||||
|
if len(args) > 0 {
|
||||||
|
n, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil || n < 0 || n > 60000 {
|
||||||
|
return fmt.Errorf("delay must be 0-60000 ms")
|
||||||
|
}
|
||||||
|
delay = n
|
||||||
|
}
|
||||||
|
if s.Replay.AtEnd() {
|
||||||
|
display.Println(display.Yellow, "At the end of the game; 'first' to start over")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for !s.Replay.AtEnd() {
|
||||||
|
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||||
|
s.Replay.Next()
|
||||||
|
if err := showReplayPosition(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fenHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return errNotReplaying
|
||||||
|
}
|
||||||
|
display.Line(s.Replay.FEN())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveHandler(s *session.Session, args []string) error {
|
||||||
|
if s.Replay == nil {
|
||||||
|
display.Println(display.Yellow, "Not replaying")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.Replay = nil
|
||||||
|
display.Println(display.Green, "Back to the live session")
|
||||||
|
if s.CurrentGame != "" {
|
||||||
|
display.Println(display.Cyan, "Current game: %s", s.CurrentGame)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showReplayPosition prints the board at the current ply with its label.
|
||||||
|
func showReplayPosition(s *session.Session) error {
|
||||||
|
r := s.Replay
|
||||||
|
board, err := display.BoardFromFEN(r.FEN())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stored position cannot be shown: %w", err)
|
||||||
|
}
|
||||||
|
display.Line()
|
||||||
|
display.RenderBoard(board)
|
||||||
|
label := r.Label(r.Ply)
|
||||||
|
if r.Ply == 0 {
|
||||||
|
label = "initial position"
|
||||||
|
}
|
||||||
|
display.Printf("\nPly %d/%d %s\n", r.Ply, r.Len(), display.C(display.Cyan, label))
|
||||||
|
if r.AtEnd() && r.History.Result != "" {
|
||||||
|
display.Println(display.Green, "Result: %s", r.Result())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayPrompt is the prompt segment while replaying.
|
||||||
|
func ReplayPrompt(s *session.Session) string {
|
||||||
|
if s.Replay == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" replay %d/%d", s.Replay.Ply, s.Replay.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgnSaveArgs splits "pgn save <path> [gameId] [ply]" style arguments:
|
||||||
|
// a "save" followed by a path, and the remaining arguments.
|
||||||
|
func pgnSaveArgs(args []string) (path string, rest []string, err error) {
|
||||||
|
for i, arg := range args {
|
||||||
|
if arg == "save" {
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return "", nil, fmt.Errorf("usage: pgn save <path>")
|
||||||
|
}
|
||||||
|
rest = append(append([]string{}, args[:i]...), args[i+2:]...)
|
||||||
|
return args[i+1], rest, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describePGNTarget(gameID string, ply int) string {
|
||||||
|
if ply >= 0 {
|
||||||
|
return fmt.Sprintf("%s (first %d plies)", gameID, ply)
|
||||||
|
}
|
||||||
|
return gameID
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimPGN(text string) string {
|
||||||
|
return strings.TrimRight(text, "\n") + "\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/client/api"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/replay"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPGNSaveArgs(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
args []string
|
||||||
|
path string
|
||||||
|
rest []string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
"none": {[]string{"abc", "3"}, "", []string{"abc", "3"}, false},
|
||||||
|
"save last": {[]string{"abc", "save", "out.pgn"}, "out.pgn", []string{"abc"}, false},
|
||||||
|
"save first": {[]string{"save", "out.pgn", "abc", "3"}, "out.pgn", []string{"abc", "3"}, false},
|
||||||
|
"missing path": {[]string{"save"}, "", nil, true},
|
||||||
|
} {
|
||||||
|
path, rest, err := pgnSaveArgs(tc.args)
|
||||||
|
if (err != nil) != tc.err || path != tc.path || len(rest) != len(tc.rest) {
|
||||||
|
t.Errorf("%s: path %q rest %v err %v", name, path, rest, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range rest {
|
||||||
|
if rest[i] != tc.rest[i] {
|
||||||
|
t.Errorf("%s: rest = %v, want %v", name, rest, tc.rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayRefusesLiveCommandsAndPrompt(t *testing.T) {
|
||||||
|
s := &session.Session{APIBaseURL: "http://127.0.0.1:1", Client: api.New("http://127.0.0.1:1")}
|
||||||
|
r := NewRegistry(s)
|
||||||
|
if ReplayPrompt(s) != "" {
|
||||||
|
t.Error("prompt segment while live")
|
||||||
|
}
|
||||||
|
s.Replay = replay.New(&api.GameHistoryResponse{
|
||||||
|
GameID: "g", InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||||
|
Moves: []api.HistoryMove{{MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", PlayerColor: "w"}},
|
||||||
|
})
|
||||||
|
if got := ReplayPrompt(s); got != " replay 0/1" {
|
||||||
|
t.Errorf("prompt segment = %q", got)
|
||||||
|
}
|
||||||
|
// A live command is refused without touching the client (no server).
|
||||||
|
r.Execute("move e2e4")
|
||||||
|
r.Execute("next")
|
||||||
|
if s.Replay.Ply != 1 || ReplayPrompt(s) != " replay 1/1" {
|
||||||
|
t.Errorf("after next: ply %d", s.Replay.Ply)
|
||||||
|
}
|
||||||
|
r.Execute("live")
|
||||||
|
if s.Replay != nil {
|
||||||
|
t.Error("live did not end the replay")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"new", "join", "move", "computer", "undo", "delete", "poll", "resign", "draw"} {
|
||||||
|
if !liveOnly[name] {
|
||||||
|
t.Errorf("%s is not refused while replaying", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !js && !wasm
|
||||||
|
|
||||||
|
package command
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// savePGN writes a PGN export to path.
|
||||||
|
func savePGN(path, text string) error {
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/client/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
// savePGN has no file system in the browser: the PGN is printed instead,
|
||||||
|
// to be copied from the terminal.
|
||||||
|
func savePGN(path, text string) error {
|
||||||
|
display.Write(text)
|
||||||
|
return fmt.Errorf("files cannot be saved from the browser; the PGN is printed above")
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ func RenderBoard(asciiBoard string) {
|
|||||||
Print(Reset, "%c", char)
|
Print(Reset, "%c", char)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Fprintln(Out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ func C(color, text string) string {
|
|||||||
|
|
||||||
// Print outputs colored text immediately
|
// Print outputs colored text immediately
|
||||||
func Print(color, format string, args ...any) {
|
func Print(color, format string, args ...any) {
|
||||||
fmt.Printf(C(color, format), args...)
|
fmt.Fprintf(Out, C(color, format), args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Println outputs colored text with newline
|
// Println outputs colored text with newline
|
||||||
func Println(color, format string, args ...any) {
|
func Println(color, format string, args ...any) {
|
||||||
fmt.Println(C(color, fmt.Sprintf(format, args...)))
|
fmt.Fprintln(Out, C(color, fmt.Sprintf(format, args...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build creates a multi-colored string
|
// Build creates a multi-colored string
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BoardFromFEN renders a FEN's piece placement in the server's ASCII board
|
||||||
|
// format (files on the first and last lines, ranks at both ends of a row,
|
||||||
|
// "." for an empty square), so RenderBoard colors it the same way.
|
||||||
|
func BoardFromFEN(fen string) (string, error) {
|
||||||
|
fields := strings.Fields(fen)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", fmt.Errorf("empty FEN")
|
||||||
|
}
|
||||||
|
rows := strings.Split(fields[0], "/")
|
||||||
|
if len(rows) != 8 {
|
||||||
|
return "", fmt.Errorf("FEN has %d ranks, want 8", len(rows))
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(" a b c d e f g h\n")
|
||||||
|
for i, row := range rows {
|
||||||
|
rank := byte('8' - i)
|
||||||
|
b.WriteByte(rank)
|
||||||
|
b.WriteByte(' ')
|
||||||
|
files := 0
|
||||||
|
for _, c := range row {
|
||||||
|
switch {
|
||||||
|
case c >= '1' && c <= '8':
|
||||||
|
for n := 0; n < int(c-'0'); n++ {
|
||||||
|
b.WriteString(". ")
|
||||||
|
}
|
||||||
|
files += int(c - '0')
|
||||||
|
case strings.ContainsRune("pnbrqkPNBRQK", c):
|
||||||
|
b.WriteRune(c)
|
||||||
|
b.WriteByte(' ')
|
||||||
|
files++
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid FEN character %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files != 8 {
|
||||||
|
return "", fmt.Errorf("rank %c has %d files, want 8", rank, files)
|
||||||
|
}
|
||||||
|
b.WriteByte(rank)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString(" a b c d e f g h")
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBoardFromFEN(t *testing.T) {
|
||||||
|
got, err := BoardFromFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := " a b c d e f g h\n" +
|
||||||
|
"8 r n b q k b n r 8\n" +
|
||||||
|
"7 p p p p p p p p 7\n" +
|
||||||
|
"6 . . . . . . . . 6\n" +
|
||||||
|
"5 . . . . . . . . 5\n" +
|
||||||
|
"4 . . . . P . . . 4\n" +
|
||||||
|
"3 . . . . . . . . 3\n" +
|
||||||
|
"2 P P P P . P P P 2\n" +
|
||||||
|
"1 R N B Q K B N R 1\n" +
|
||||||
|
" a b c d e f g h"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("board:\n%s\nwant:\n%s", got, want)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "8/8/8/8/8/8/8 w - - 0 1", "9/8/8/8/8/8/8/8 w - - 0 1", "x7/8/8/8/8/8/8/8 w - - 0 1"} {
|
||||||
|
if _, err := BoardFromFEN(bad); err == nil {
|
||||||
|
t.Errorf("BoardFromFEN(%q) accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,5 +12,5 @@ func PrettyPrintJSON(v any) {
|
|||||||
Print(Red, "Error formatting JSON: %s\n", err.Error())
|
Print(Red, "Error formatting JSON: %s\n", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(string(data))
|
fmt.Fprintln(Out, string(data))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Out receives everything the client prints. A paged command runs with Out
|
||||||
|
// swapped for a buffer, which Page then shows one screen at a time.
|
||||||
|
var Out io.Writer = os.Stdout
|
||||||
|
|
||||||
|
// Printf, Line and Write are fmt.Printf, fmt.Println and fmt.Print to Out.
|
||||||
|
func Printf(format string, args ...any) { fmt.Fprintf(Out, format, args...) }
|
||||||
|
func Line(args ...any) { fmt.Fprintln(Out, args...) }
|
||||||
|
func Write(args ...any) { fmt.Fprint(Out, args...) }
|
||||||
|
|
||||||
|
var ansiPattern = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]")
|
||||||
|
|
||||||
|
// TermSize returns the terminal's rows and columns, or zeros when the output
|
||||||
|
// is not a terminal (then nothing is paged). Platform files set it.
|
||||||
|
var TermSize = func() (rows, cols int) { return 0, 0 }
|
||||||
|
|
||||||
|
// Page prints text to Out one screen at a time. After each full screen it
|
||||||
|
// asks to continue; readLine supplies the answer (an empty line continues,
|
||||||
|
// "q" stops). Without a terminal, or when the text fits, it prints at once.
|
||||||
|
func Page(text string, readLine func() (string, bool)) {
|
||||||
|
rows, cols := TermSize()
|
||||||
|
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||||
|
if rows < 4 || readLine == nil || visualRows(lines, cols) <= rows-1 {
|
||||||
|
if text != "" {
|
||||||
|
fmt.Fprint(Out, strings.TrimRight(text, "\n")+"\n")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageRows := rows - 1 // one line for the prompt
|
||||||
|
used, i := 0, 0
|
||||||
|
for i < len(lines) {
|
||||||
|
need := wrappedRows(lines[i], cols)
|
||||||
|
if used > 0 && used+need > pageRows {
|
||||||
|
remaining := len(lines) - i
|
||||||
|
fmt.Fprintf(Out, "%s-- More (%d lines left; Enter continues, q quits) --%s", Yellow, remaining, Reset)
|
||||||
|
answer, ok := readLine()
|
||||||
|
// Clear the prompt and the echoed answer line.
|
||||||
|
fmt.Fprint(Out, "\r\x1b[2K\x1b[1A\r\x1b[2K")
|
||||||
|
if !ok || strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "q") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintln(Out, lines[i])
|
||||||
|
used += need
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func visualRows(lines []string, cols int) int {
|
||||||
|
total := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
total += wrappedRows(line, cols)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrappedRows is how many terminal rows a line takes, ignoring colour codes.
|
||||||
|
func wrappedRows(line string, cols int) int {
|
||||||
|
if cols <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
width := utf8.RuneCountInString(ansiPattern.ReplaceAllString(line, ""))
|
||||||
|
if width == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return (width + cols - 1) / cols
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPagePrintsScreensAndStopsOnQ(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prevOut, prevSize := Out, TermSize
|
||||||
|
Out = &buf
|
||||||
|
TermSize = func() (int, int) { return 5, 80 } // 4 lines a page
|
||||||
|
defer func() { Out, TermSize = prevOut, prevSize }()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for i := 1; i <= 10; i++ {
|
||||||
|
lines = append(lines, "line "+string(rune('0'+i%10)))
|
||||||
|
}
|
||||||
|
answers := []string{"", "q"}
|
||||||
|
reads := 0
|
||||||
|
readLine := func() (string, bool) {
|
||||||
|
if reads >= len(answers) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
reads++
|
||||||
|
return answers[reads-1], true
|
||||||
|
}
|
||||||
|
Page(strings.Join(lines, "\n")+"\n", readLine)
|
||||||
|
out := buf.String()
|
||||||
|
if reads != 2 {
|
||||||
|
t.Errorf("prompted %d times, want 2", reads)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "line 1\nline 2\nline 3\nline 4\n") || !strings.Contains(out, "More (6 lines left") {
|
||||||
|
t.Errorf("first page = %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "line 5\nline 6\nline 7\nline 8\n") || strings.Contains(out, "line 9") {
|
||||||
|
t.Errorf("second page / stop = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fits on one screen: no prompt. A wrapped long line counts as two rows.
|
||||||
|
buf.Reset()
|
||||||
|
reads = 0
|
||||||
|
Page("a\nb\n", readLine)
|
||||||
|
if buf.String() != "a\nb\n" || reads != 0 {
|
||||||
|
t.Errorf("short text = %q, prompts %d", buf.String(), reads)
|
||||||
|
}
|
||||||
|
if wrappedRows(Red+strings.Repeat("x", 100)+Reset, 80) != 2 || wrappedRows("", 80) != 1 {
|
||||||
|
t.Error("wrapped rows")
|
||||||
|
}
|
||||||
|
// No terminal: everything at once.
|
||||||
|
TermSize = func() (int, int) { return 0, 0 }
|
||||||
|
buf.Reset()
|
||||||
|
Page(strings.Join(lines, "\n"), readLine)
|
||||||
|
if strings.Count(buf.String(), "\n") != 10 || reads != 0 {
|
||||||
|
t.Errorf("unpaged = %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build !js && !wasm
|
||||||
|
|
||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
TermSize = func() (rows, cols int) {
|
||||||
|
fd := int(os.Stdout.Fd())
|
||||||
|
if !term.IsTerminal(fd) {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
cols, rows, err := term.GetSize(fd)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return rows, cols
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package display
|
||||||
|
|
||||||
|
import "syscall/js"
|
||||||
|
|
||||||
|
// The page's terminal wrapper (terminal.js) publishes xterm's size as
|
||||||
|
// globalThis.chessTerminal.rows and .cols.
|
||||||
|
func init() {
|
||||||
|
TermSize = func() (rows, cols int) {
|
||||||
|
t := js.Global().Get("chessTerminal")
|
||||||
|
if t.IsUndefined() || t.IsNull() {
|
||||||
|
return 24, 80
|
||||||
|
}
|
||||||
|
rows, cols = t.Get("rows").Int(), t.Get("cols").Int()
|
||||||
|
if rows <= 0 || cols <= 0 {
|
||||||
|
return 24, 80
|
||||||
|
}
|
||||||
|
return rows, cols
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Package replay steps through a stored game's history in the terminal
|
||||||
|
// client. The state is separate from the live session: it holds the
|
||||||
|
// history the server returned and a ply, and renders positions from the
|
||||||
|
// stored FENs; it never computes a move.
|
||||||
|
package replay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/client/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is a stored game and the ply shown: 0 is the initial position,
|
||||||
|
// Len() the position after the last move.
|
||||||
|
type State struct {
|
||||||
|
History *api.GameHistoryResponse
|
||||||
|
Ply int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a replay at the initial position.
|
||||||
|
func New(history *api.GameHistoryResponse) *State {
|
||||||
|
return &State{History: history}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len is the number of stored plies.
|
||||||
|
func (s *State) Len() int { return len(s.History.Moves) }
|
||||||
|
|
||||||
|
// Goto shows the position after ply plies, clamped to the stored line, and
|
||||||
|
// returns the ply shown.
|
||||||
|
func (s *State) Goto(ply int) int {
|
||||||
|
if ply < 0 {
|
||||||
|
ply = 0
|
||||||
|
}
|
||||||
|
if ply > s.Len() {
|
||||||
|
ply = s.Len()
|
||||||
|
}
|
||||||
|
s.Ply = ply
|
||||||
|
return s.Ply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Next() int { return s.Goto(s.Ply + 1) }
|
||||||
|
func (s *State) Prev() int { return s.Goto(s.Ply - 1) }
|
||||||
|
func (s *State) First() int { return s.Goto(0) }
|
||||||
|
func (s *State) Last() int { return s.Goto(s.Len()) }
|
||||||
|
|
||||||
|
// AtEnd reports whether the last stored position is shown.
|
||||||
|
func (s *State) AtEnd() bool { return s.Ply >= s.Len() }
|
||||||
|
|
||||||
|
// FEN is the stored position at the current ply.
|
||||||
|
func (s *State) FEN() string {
|
||||||
|
if s.Ply == 0 {
|
||||||
|
return s.History.InitialFEN
|
||||||
|
}
|
||||||
|
return s.History.Moves[s.Ply-1].FENAfterMove
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is the ply-th move (from 1), or nil for the initial position.
|
||||||
|
func (s *State) Move(ply int) *api.HistoryMove {
|
||||||
|
if ply < 1 || ply > s.Len() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s.History.Moves[ply-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// startsWithBlack and firstMoveNumber come from the initial FEN, so a game
|
||||||
|
// from a custom position numbers its moves as its PGN does.
|
||||||
|
func (s *State) start() (number int, black bool) {
|
||||||
|
parts := strings.Fields(s.History.InitialFEN)
|
||||||
|
number = 1
|
||||||
|
if len(parts) >= 6 {
|
||||||
|
if n, err := strconv.Atoi(parts[5]); err == nil && n > 0 {
|
||||||
|
number = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
black = len(parts) >= 2 && parts[1] == "b"
|
||||||
|
return number, black
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label names a ply in PGN style: "2. Nf3", "2... Nf6", or "start".
|
||||||
|
func (s *State) Label(ply int) string {
|
||||||
|
move := s.Move(ply)
|
||||||
|
if move == nil {
|
||||||
|
return "start"
|
||||||
|
}
|
||||||
|
first, black := s.start()
|
||||||
|
offset := ply - 1
|
||||||
|
if black {
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
number := first + offset/2
|
||||||
|
text := move.SAN
|
||||||
|
if text == "" {
|
||||||
|
text = move.MoveUCI
|
||||||
|
}
|
||||||
|
if move.PlayerColor == "b" {
|
||||||
|
return fmt.Sprintf("%d... %s", number, text)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d. %s", number, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movetext lists the moves with numbers, the current ply in brackets,
|
||||||
|
// wrapped at width columns (80 when width is not positive).
|
||||||
|
func (s *State) Movetext(width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
width = 80
|
||||||
|
}
|
||||||
|
first, black := s.start()
|
||||||
|
var tokens []string
|
||||||
|
number := first
|
||||||
|
for i, move := range s.History.Moves {
|
||||||
|
text := move.SAN
|
||||||
|
if text == "" {
|
||||||
|
text = move.MoveUCI
|
||||||
|
}
|
||||||
|
if i+1 == s.Ply {
|
||||||
|
text = "[" + text + "]"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case i == 0 && black:
|
||||||
|
tokens = append(tokens, fmt.Sprintf("%d...", number), text)
|
||||||
|
number++
|
||||||
|
case move.PlayerColor == "w":
|
||||||
|
tokens = append(tokens, fmt.Sprintf("%d.", number), text)
|
||||||
|
default:
|
||||||
|
tokens = append(tokens, text)
|
||||||
|
number++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return "(no moves)"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
col := 0
|
||||||
|
for _, token := range tokens {
|
||||||
|
if col > 0 && col+1+len(token) > width {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
col = 0
|
||||||
|
} else if col > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
b.WriteString(token)
|
||||||
|
col += len(token)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Players names the sides: the stored name, the engine and level, or
|
||||||
|
// "Anonymous".
|
||||||
|
func (s *State) Players() (white, black string) {
|
||||||
|
return playerLabel(s.History.Players.White), playerLabel(s.History.Players.Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerLabel(p api.PlayerInfo) string {
|
||||||
|
switch {
|
||||||
|
case p.Type == 2:
|
||||||
|
return fmt.Sprintf("Stockfish L%d", p.Level)
|
||||||
|
case p.Name != "":
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
return "Anonymous"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result describes the outcome: "1-0 (checkmate)" or "ongoing".
|
||||||
|
func (s *State) Result() string {
|
||||||
|
if s.History.Result == "" {
|
||||||
|
return "ongoing"
|
||||||
|
}
|
||||||
|
if s.History.Termination != "" {
|
||||||
|
return fmt.Sprintf("%s (%s)", s.History.PGNResult, strings.ReplaceAll(s.History.Termination, "_", " "))
|
||||||
|
}
|
||||||
|
return s.History.PGNResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package replay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/client/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNavigationClampsAndSelectsStoredFEN(t *testing.T) {
|
||||||
|
h := &api.GameHistoryResponse{
|
||||||
|
InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||||
|
Moves: []api.HistoryMove{
|
||||||
|
{MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "fen1", PlayerColor: "w"},
|
||||||
|
{MoveNumber: 2, MoveUCI: "c7c5", SAN: "c5", FENAfterMove: "fen2", PlayerColor: "b"},
|
||||||
|
{MoveNumber: 3, MoveUCI: "g1f3", SAN: "Nf3", FENAfterMove: "fen3", PlayerColor: "w"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := New(h)
|
||||||
|
if s.Ply != 0 || s.FEN() != h.InitialFEN || s.Label(0) != "start" {
|
||||||
|
t.Fatalf("new state: ply %d fen %q label %q", s.Ply, s.FEN(), s.Label(0))
|
||||||
|
}
|
||||||
|
if got := s.Next(); got != 1 || s.FEN() != "fen1" || s.Label(1) != "1. e4" {
|
||||||
|
t.Errorf("next: ply %d fen %q label %q", got, s.FEN(), s.Label(1))
|
||||||
|
}
|
||||||
|
if got := s.Last(); got != 3 || !s.AtEnd() || s.Label(3) != "2. Nf3" {
|
||||||
|
t.Errorf("last: ply %d atEnd %v label %q", got, s.AtEnd(), s.Label(3))
|
||||||
|
}
|
||||||
|
if got := s.Next(); got != 3 {
|
||||||
|
t.Errorf("next past the end = %d", got)
|
||||||
|
}
|
||||||
|
if got := s.Goto(-5); got != 0 || s.FEN() != h.InitialFEN {
|
||||||
|
t.Errorf("goto -5 = %d", got)
|
||||||
|
}
|
||||||
|
if got := s.Goto(2); got != 2 || s.Label(2) != "1... c5" || s.FEN() != "fen2" {
|
||||||
|
t.Errorf("goto 2 = %d label %q", got, s.Label(2))
|
||||||
|
}
|
||||||
|
if got := s.Prev(); got != 1 {
|
||||||
|
t.Errorf("prev = %d", got)
|
||||||
|
}
|
||||||
|
if s.Move(0) != nil || s.Move(4) != nil || s.Move(3).SAN != "Nf3" {
|
||||||
|
t.Error("Move bounds")
|
||||||
|
}
|
||||||
|
want := "1. e4 c5 2. [Nf3]"
|
||||||
|
s.Last()
|
||||||
|
if got := s.Movetext(80); got != want {
|
||||||
|
t.Errorf("movetext = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := s.Movetext(8); got != "1. e4 c5\n2. [Nf3]" {
|
||||||
|
t.Errorf("wrapped movetext = %q", got)
|
||||||
|
}
|
||||||
|
if s.Result() != "ongoing" {
|
||||||
|
t.Errorf("result = %q", s.Result())
|
||||||
|
}
|
||||||
|
h.Result, h.PGNResult, h.Termination = "white_wins", "1-0", "checkmate"
|
||||||
|
if s.Result() != "1-0 (checkmate)" {
|
||||||
|
t.Errorf("result = %q", s.Result())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomStartNumbersFromItsMoveAndSide(t *testing.T) {
|
||||||
|
h := &api.GameHistoryResponse{
|
||||||
|
InitialFEN: "4k3/8/8/8/8/8/8/4K2R b K - 0 40",
|
||||||
|
Moves: []api.HistoryMove{
|
||||||
|
{MoveNumber: 1, MoveUCI: "e8d8", SAN: "Kd8", FENAfterMove: "f1", PlayerColor: "b"},
|
||||||
|
{MoveNumber: 2, MoveUCI: "e1g1", SAN: "O-O", FENAfterMove: "f2", PlayerColor: "w"},
|
||||||
|
{MoveNumber: 3, MoveUCI: "d8c8", SAN: "Kc8", FENAfterMove: "f3", PlayerColor: "b"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := New(h)
|
||||||
|
for ply, want := range map[int]string{1: "40... Kd8", 2: "41. O-O", 3: "41... Kc8"} {
|
||||||
|
if got := s.Label(ply); got != want {
|
||||||
|
t.Errorf("label %d = %q, want %q", ply, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Goto(2)
|
||||||
|
if got := s.Movetext(80); got != "40... Kd8 41. [O-O] Kc8" {
|
||||||
|
t.Errorf("movetext = %q", got)
|
||||||
|
}
|
||||||
|
if w, b := s.Players(); w != "Anonymous" || b != "Anonymous" {
|
||||||
|
t.Errorf("players = %q %q", w, b)
|
||||||
|
}
|
||||||
|
h.Players.White = api.PlayerInfo{Type: 2, Level: 7}
|
||||||
|
h.Players.Black = api.PlayerInfo{Type: 1, Name: "alice"}
|
||||||
|
if w, b := s.Players(); w != "Stockfish L7" || b != "alice" {
|
||||||
|
t.Errorf("players = %q %q", w, b)
|
||||||
|
}
|
||||||
|
empty := New(&api.GameHistoryResponse{InitialFEN: h.InitialFEN})
|
||||||
|
if empty.Movetext(80) != "(no moves)" || empty.Last() != 0 {
|
||||||
|
t.Error("empty history")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package session
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/lixenwraith/chess/internal/client/api"
|
"github.com/lixenwraith/chess/internal/client/api"
|
||||||
|
"github.com/lixenwraith/chess/internal/client/replay"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session maintains client state and configuration
|
// Session maintains client state and configuration
|
||||||
@@ -13,7 +14,14 @@ type Session struct {
|
|||||||
Username string
|
Username string
|
||||||
LastMoveCount int
|
LastMoveCount int
|
||||||
GamesCursor string // next page of the "games" listing
|
GamesCursor string // next page of the "games" listing
|
||||||
|
GamesPage []api.GameSummary // the last "games" page, for "replay <index>"
|
||||||
Client *api.Client
|
Client *api.Client
|
||||||
|
// Replay is the stored game being stepped through, nil while live. The
|
||||||
|
// live game, user, and poll state above are untouched by a replay.
|
||||||
|
Replay *replay.State
|
||||||
|
// ReadLine reads the next input line (false at end of input); the pager
|
||||||
|
// uses it to wait between screens.
|
||||||
|
ReadLine func() (string, bool)
|
||||||
Verbose bool
|
Verbose bool
|
||||||
// Game state for prompt
|
// Game state for prompt
|
||||||
CurrentGameState *api.GameResponse
|
CurrentGameState *api.GameResponse
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,7 +42,9 @@ type UndoRequest struct {
|
|||||||
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
||||||
// reports what a draw request did ("offered", "accepted", "declined") and is
|
// reports what a draw request did ("offered", "accepted", "declined") and is
|
||||||
// set only on responses to that request. Concession is the game's first
|
// set only on responses to that request. Concession is the game's first
|
||||||
// resignation or agreed draw, kept after an undo continues play.
|
// resignation or agreed draw, kept after an undo continues play. SAN runs
|
||||||
|
// parallel to Moves, derived by the rules core from the position before each
|
||||||
|
// move; an entry is empty only for a move the core cannot notate.
|
||||||
type GameResponse struct {
|
type GameResponse struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
FEN string `json:"fen"`
|
FEN string `json:"fen"`
|
||||||
@@ -53,6 +55,7 @@ type GameResponse struct {
|
|||||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||||
Concession *Concession `json:"concession,omitempty"`
|
Concession *Concession `json:"concession,omitempty"`
|
||||||
Moves []string `json:"moves"`
|
Moves []string `json:"moves"`
|
||||||
|
SAN []string `json:"san"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -79,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
|
||||||
@@ -96,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"`
|
||||||
}
|
}
|
||||||
@@ -113,16 +141,21 @@ 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; 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"`
|
||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
PGNResult string `json:"pgnResult"`
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ const (
|
|||||||
ErrConflict = "GAME_CONFLICT"
|
ErrConflict = "GAME_CONFLICT"
|
||||||
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
||||||
ErrRouteNotFound = "NOT_FOUND"
|
ErrRouteNotFound = "NOT_FOUND"
|
||||||
|
ErrNotConfigured = "NOT_CONFIGURED"
|
||||||
)
|
)
|
||||||
@@ -35,6 +35,17 @@ type DeleteAccountRequest struct {
|
|||||||
Password string `json:"password" validate:"required"`
|
Password string `json:"password" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecoverRequest asks for a password-reset email.
|
||||||
|
type RecoverRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required"` // username or email
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPasswordRequest sets a new password with the token from the email.
|
||||||
|
type ResetPasswordRequest struct {
|
||||||
|
Token string `json:"token" validate:"required"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// AuthResponse contains JWT token and user information
|
// AuthResponse contains JWT token and user information
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -322,3 +333,104 @@ func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecoverHandler emails a password-reset link. The answer is the same whether
|
||||||
|
// or not the account exists or has an email, so nothing is revealed; only a
|
||||||
|
// missing transport (503) or a delivery failure (502) is reported.
|
||||||
|
func (h *HTTPHandler) RecoverHandler(c *fiber.Ctx) error {
|
||||||
|
var req RecoverRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil || strings.TrimSpace(req.Identifier) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid request body",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "identifier (username or email) is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Identifier) > 254 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid identifier", Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.RequestPasswordReset(req.Identifier); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrMailDisabled):
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||||
|
Error: "password recovery is not available on this server", Code: core.ErrNotConfigured,
|
||||||
|
})
|
||||||
|
case errors.Is(err, service.ErrMailFailed):
|
||||||
|
return c.Status(fiber.StatusBadGateway).JSON(core.ErrorResponse{
|
||||||
|
Error: "the reset email could not be sent; try again later", Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||||
|
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to start password recovery", Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||||||
|
"message": "If the account has an email address, a reset link has been sent to it.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPasswordHandler sets a new password from a reset link and signs the
|
||||||
|
// user in, returning a session token like login.
|
||||||
|
func (h *HTTPHandler) ResetPasswordHandler(c *fiber.Ctx) error {
|
||||||
|
var req ResetPasswordRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.Password == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid request body",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "token and password are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := validatePassword(req.Password); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "weak password",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user, sessionID, err := h.svc.ResetPassword(req.Token, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrResetInvalid):
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid or expired reset link",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "request a new link from the sign-in dialog",
|
||||||
|
})
|
||||||
|
case errors.Is(err, service.ErrAuthBusy):
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||||
|
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
|
||||||
|
})
|
||||||
|
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||||
|
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to reset password", Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to generate token", Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
UserID: user.UserID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
ExpiresAt: time.Now().Add(service.SessionTTL),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -93,10 +94,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
|||||||
TimeZone: "UTC",
|
TimeZone: "UTC",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
// ETag and Content-Disposition are not CORS-safelisted: a browser client
|
||||||
|
// on another origin (the embedded web server's port, in development) can
|
||||||
|
// only revalidate histories and name PGN downloads when they are exposed.
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "*",
|
||||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
AllowHeaders: "Origin,Content-Type,Accept,Authorization,If-None-Match",
|
||||||
|
ExposeHeaders: "ETag,Content-Disposition",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Health check (no rate limit)
|
// Health check (no rate limit)
|
||||||
@@ -136,6 +141,33 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
|||||||
},
|
},
|
||||||
}), h.LoginHandler)
|
}), h.LoginHandler)
|
||||||
|
|
||||||
|
// Password recovery: 3 requests a minute per IP, each of which may send
|
||||||
|
// an email; the reset itself is limited like login.
|
||||||
|
auth.Post("/recover", limiter.New(limiter.Config{
|
||||||
|
Max: 3,
|
||||||
|
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: "3 recovery requests per minute allowed",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}), h.RecoverHandler)
|
||||||
|
auth.Post("/reset", limiter.New(limiter.Config{
|
||||||
|
Max: 10,
|
||||||
|
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: "10 password resets per minute allowed",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}), h.ResetPasswordHandler)
|
||||||
|
|
||||||
// Create token validator closure
|
// Create token validator closure
|
||||||
validateToken := svc.ValidateToken
|
validateToken := svc.ValidateToken
|
||||||
|
|
||||||
@@ -198,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
|
||||||
}
|
}
|
||||||
@@ -259,6 +305,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
|||||||
"status": status,
|
"status": status,
|
||||||
"time": time.Now().Unix(),
|
"time": time.Now().Unix(),
|
||||||
"storage": storageHealth,
|
"storage": storageHealth,
|
||||||
|
"mail": h.svc.MailHealth(),
|
||||||
"version": h.version,
|
"version": h.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -784,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 == "" {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ func TestAPIRoutes(t *testing.T) {
|
|||||||
"GET /api/auth/me",
|
"GET /api/auth/me",
|
||||||
"POST /api/auth/logout",
|
"POST /api/auth/logout",
|
||||||
"DELETE /api/auth/me",
|
"DELETE /api/auth/me",
|
||||||
|
"POST /api/auth/recover",
|
||||||
|
"POST /api/auth/reset",
|
||||||
"POST /api/games",
|
"POST /api/games",
|
||||||
"GET /api/games/:gameId",
|
"GET /api/games/:gameId",
|
||||||
"GET /api/games/:gameId/history",
|
"GET /api/games/:gameId/history",
|
||||||
@@ -97,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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Package mail sends the server's few transactional messages, today the
|
||||||
|
// password-reset link. A Sender is an SMTP relay (production), a directory
|
||||||
|
// of .eml files (development and tests), or nothing, in which case the
|
||||||
|
// features that need mail are off.
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one plain-text email.
|
||||||
|
type Message struct {
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender delivers messages.
|
||||||
|
type Sender interface {
|
||||||
|
Send(ctx context.Context, m Message) error
|
||||||
|
// Describe names the transport for logs, without secrets.
|
||||||
|
Describe() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP delivers through a relay. Username and Password are optional: a relay
|
||||||
|
// inside the same network usually accepts mail from this host without them.
|
||||||
|
// STARTTLS is used when the relay offers it; credentials are only ever sent
|
||||||
|
// over TLS (or to localhost), which net/smtp enforces.
|
||||||
|
type SMTP struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
From string // "Chess <chess@example.com>" or a bare address
|
||||||
|
// Timeout bounds the whole exchange; 30 seconds when zero.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe names the relay.
|
||||||
|
func (s *SMTP) Describe() string {
|
||||||
|
auth := "no authentication"
|
||||||
|
if s.Username != "" {
|
||||||
|
auth = "as " + s.Username
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("smtp %s:%d (%s), from %s", s.Host, s.Port, auth, s.From)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send delivers one message through the relay.
|
||||||
|
func (s *SMTP) Send(ctx context.Context, m Message) error {
|
||||||
|
from, err := mail.ParseAddress(s.From)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mail from %q: %w", s.From, err)
|
||||||
|
}
|
||||||
|
to, err := mail.ParseAddress(m.To)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mail to %q: %w", m.To, err)
|
||||||
|
}
|
||||||
|
timeout := s.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if until := time.Until(deadline); until < timeout {
|
||||||
|
timeout = until
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
|
||||||
|
conn, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, s.Host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp handshake with %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
|
if err := client.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||||
|
return fmt.Errorf("starttls with %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Username != "" {
|
||||||
|
if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil {
|
||||||
|
return fmt.Errorf("smtp authentication with %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Mail(from.Address); err != nil {
|
||||||
|
return fmt.Errorf("smtp from: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(to.Address); err != nil {
|
||||||
|
return fmt.Errorf("smtp to: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(Format(s.From, m)); err != nil {
|
||||||
|
return fmt.Errorf("smtp write: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("smtp send: %w", err)
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir writes each message to a file in a directory instead of sending it,
|
||||||
|
// for development and tests. The file holds exactly what SMTP would carry.
|
||||||
|
type Dir struct {
|
||||||
|
Path string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe names the directory.
|
||||||
|
func (d *Dir) Describe() string {
|
||||||
|
return fmt.Sprintf("directory %s, from %s", d.Path, d.From)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send writes the message as <unix-nanos>-<random>.eml.
|
||||||
|
func (d *Dir) Send(_ context.Context, m Message) error {
|
||||||
|
if _, err := mail.ParseAddress(m.To); err != nil {
|
||||||
|
return fmt.Errorf("mail to %q: %w", m.To, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(d.Path, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var nonce [4]byte
|
||||||
|
rand.Read(nonce[:])
|
||||||
|
name := fmt.Sprintf("%d-%s.eml", time.Now().UnixNano(), hex.EncodeToString(nonce[:]))
|
||||||
|
return os.WriteFile(filepath.Join(d.Path, name), Format(d.From, m), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format renders the message as RFC 5322 text with CRLF line ends: headers,
|
||||||
|
// a blank line, the UTF-8 body. Header values are kept to one line.
|
||||||
|
func Format(from string, m Message) []byte {
|
||||||
|
var nonce [8]byte
|
||||||
|
rand.Read(nonce[:])
|
||||||
|
domain := "localhost"
|
||||||
|
if addr, err := mail.ParseAddress(from); err == nil {
|
||||||
|
if at := strings.LastIndex(addr.Address, "@"); at >= 0 {
|
||||||
|
domain = addr.Address[at+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
header := func(name, value string) {
|
||||||
|
value = strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
|
||||||
|
fmt.Fprintf(&b, "%s: %s\r\n", name, value)
|
||||||
|
}
|
||||||
|
header("From", from)
|
||||||
|
header("To", m.To)
|
||||||
|
header("Subject", m.Subject)
|
||||||
|
header("Date", time.Now().UTC().Format(time.RFC1123Z))
|
||||||
|
header("Message-ID", fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(nonce[:]), domain))
|
||||||
|
header("MIME-Version", "1.0")
|
||||||
|
header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
header("Content-Transfer-Encoding", "8bit")
|
||||||
|
header("Auto-Submitted", "auto-generated")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
body := strings.ReplaceAll(strings.ReplaceAll(m.Body, "\r\n", "\n"), "\n", "\r\n")
|
||||||
|
b.WriteString(body)
|
||||||
|
if !strings.HasSuffix(body, "\r\n") {
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoSender is returned by Validate for an empty configuration.
|
||||||
|
var ErrNoSender = errors.New("no mail transport configured")
|
||||||
|
|
||||||
|
// Config is the server's mail configuration, from its flags. SMTPHost or Dir
|
||||||
|
// selects the transport; both empty means no mail.
|
||||||
|
type Config struct {
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort int
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPasswordFile string
|
||||||
|
Dir string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the configured Sender, nil (with ErrNoSender) when nothing is
|
||||||
|
// configured, or an error for an inconsistent configuration.
|
||||||
|
func (c Config) Build() (Sender, error) {
|
||||||
|
switch {
|
||||||
|
case c.SMTPHost != "" && c.Dir != "":
|
||||||
|
return nil, errors.New("-smtp-host and -mail-dir are exclusive")
|
||||||
|
case c.SMTPHost == "" && c.Dir == "":
|
||||||
|
return nil, ErrNoSender
|
||||||
|
}
|
||||||
|
if c.From == "" {
|
||||||
|
return nil, errors.New("-mail-from is required with a mail transport")
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(c.From); err != nil {
|
||||||
|
return nil, fmt.Errorf("-mail-from %q: %w", c.From, err)
|
||||||
|
}
|
||||||
|
if c.Dir != "" {
|
||||||
|
if err := os.MkdirAll(c.Dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("-mail-dir: %w", err)
|
||||||
|
}
|
||||||
|
return &Dir{Path: c.Dir, From: c.From}, nil
|
||||||
|
}
|
||||||
|
port := c.SMTPPort
|
||||||
|
if port <= 0 {
|
||||||
|
port = 25
|
||||||
|
}
|
||||||
|
s := &SMTP{Host: c.SMTPHost, Port: port, Username: c.SMTPUser, From: c.From}
|
||||||
|
if c.SMTPPasswordFile != "" {
|
||||||
|
secret, err := readSecretFile(c.SMTPPasswordFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("-smtp-password-file: %w", err)
|
||||||
|
}
|
||||||
|
s.Password = secret
|
||||||
|
}
|
||||||
|
if s.Password != "" && s.Username == "" {
|
||||||
|
return nil, errors.New("-smtp-password-file needs -smtp-user")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSecretFile reads a one-line secret from a file that only its owner may
|
||||||
|
// read, as the JWT key file is.
|
||||||
|
func readSecretFile(path string) (string, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return "", fmt.Errorf("%s is not a regular file", path)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0o077 != 0 {
|
||||||
|
return "", fmt.Errorf("%s is readable by group or others (mode %04o); use mode 0600", path, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
secret := strings.TrimRight(string(data), "\r\n")
|
||||||
|
if secret == "" {
|
||||||
|
return "", fmt.Errorf("%s is empty", path)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatIsRFC5322WithCRLF(t *testing.T) {
|
||||||
|
text := string(Format("Chess <chess@example.com>", Message{
|
||||||
|
To: "alice@example.com", Subject: "Reset your\npassword", Body: "line one\nline two",
|
||||||
|
}))
|
||||||
|
head, body, ok := strings.Cut(text, "\r\n\r\n")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no header/body separator in %q", text)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"From: Chess <chess@example.com>\r\n", "To: alice@example.com\r\n",
|
||||||
|
"Subject: Reset your password\r\n", "Content-Type: text/plain; charset=utf-8\r\n", "@example.com>\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(head+"\r\n", want) {
|
||||||
|
t.Errorf("header %q missing in %q", want, head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body != "line one\r\nline two\r\n" {
|
||||||
|
t.Errorf("body = %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "\n\n") || strings.Contains(strings.ReplaceAll(text, "\r\n", ""), "\n") {
|
||||||
|
t.Error("bare LF in message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirWritesOneFilePerMessage(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "mail")
|
||||||
|
d := &Dir{Path: dir, From: "chess@example.com"}
|
||||||
|
if err := d.Send(context.Background(), Message{To: "bob@example.com", Subject: "s", Body: "b"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := d.Send(context.Background(), Message{To: "not an address", Subject: "s", Body: "b"}); err == nil {
|
||||||
|
t.Error("invalid recipient accepted")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil || len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".eml") {
|
||||||
|
t.Fatalf("entries = %v, %v", entries, err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||||
|
if !strings.Contains(string(data), "To: bob@example.com\r\n") {
|
||||||
|
t.Errorf("file = %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBuild(t *testing.T) {
|
||||||
|
if _, err := (Config{}).Build(); !errors.Is(err, ErrNoSender) {
|
||||||
|
t.Errorf("empty config: %v", err)
|
||||||
|
}
|
||||||
|
for name, c := range map[string]Config{
|
||||||
|
"both transports": {SMTPHost: "h", Dir: "d", From: "a@b.c"},
|
||||||
|
"no from": {SMTPHost: "h"},
|
||||||
|
"bad from": {SMTPHost: "h", From: "nope"},
|
||||||
|
"password no user": {SMTPHost: "h", From: "a@b.c", SMTPPasswordFile: func() string {
|
||||||
|
p := filepath.Join(t.TempDir(), "pw")
|
||||||
|
os.WriteFile(p, []byte("secret\n"), 0o600)
|
||||||
|
return p
|
||||||
|
}()},
|
||||||
|
} {
|
||||||
|
if _, err := c.Build(); err == nil {
|
||||||
|
t.Errorf("%s: accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw := filepath.Join(t.TempDir(), "pw")
|
||||||
|
os.WriteFile(pw, []byte("secret\n"), 0o644)
|
||||||
|
if _, err := (Config{SMTPHost: "h", From: "a@b.c", SMTPUser: "u", SMTPPasswordFile: pw}).Build(); err == nil {
|
||||||
|
t.Error("world-readable password file accepted")
|
||||||
|
}
|
||||||
|
os.Chmod(pw, 0o600)
|
||||||
|
sender, err := (Config{SMTPHost: "mail.internal", From: "Chess <a@b.c>", SMTPUser: "u", SMTPPasswordFile: pw}).Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, ok := sender.(*SMTP)
|
||||||
|
if !ok || s.Port != 25 || s.Password != "secret" || !strings.Contains(s.Describe(), "as u") ||
|
||||||
|
strings.Contains(s.Describe(), "secret") {
|
||||||
|
t.Errorf("smtp = %+v, %q", s, sender.Describe())
|
||||||
|
}
|
||||||
|
if d, err := (Config{Dir: "x", From: "a@b.c"}).Build(); err != nil || d.(*Dir).Path != "x" {
|
||||||
|
t.Errorf("dir = %v, %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -674,6 +674,7 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
|
|||||||
State: g.State.String(),
|
State: g.State.String(),
|
||||||
Termination: string(g.Termination),
|
Termination: string(g.Termination),
|
||||||
Moves: g.Moves,
|
Moves: g.Moves,
|
||||||
|
SAN: notateLine(g.FENs, g.Moves),
|
||||||
Players: core.PlayersResponse{
|
Players: core.PlayersResponse{
|
||||||
White: g.WhitePlayer,
|
White: g.WhitePlayer,
|
||||||
Black: g.BlackPlayer,
|
Black: g.BlackPlayer,
|
||||||
@@ -721,6 +722,27 @@ func (p *Processor) Close() error {
|
|||||||
return errors.Join(queueErr, engineErr)
|
return errors.Join(queueErr, engineErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notateLine derives SAN for each live move from the position before it,
|
||||||
|
// fens[i] being the position before moves[i]. Each ply stands alone, so a
|
||||||
|
// position the core cannot parse leaves only that entry empty (and is logged
|
||||||
|
// once, by the shadow check, when the move was made).
|
||||||
|
func notateLine(fens, moves []string) []string {
|
||||||
|
san := make([]string, len(moves))
|
||||||
|
for i, uci := range moves {
|
||||||
|
if i >= len(fens) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos, err := chess.ParseFEN(fens[i])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m, err := pos.ParseUCI(uci); err == nil {
|
||||||
|
san[i] = pos.SAN(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return san
|
||||||
|
}
|
||||||
|
|
||||||
// rulesResult is the rules core's verdict on a move, computed alongside the
|
// rulesResult is the rules core's verdict on a move, computed alongside the
|
||||||
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
||||||
// core can be trusted, or fixed, before it validates anything.
|
// core can be trusted, or fixed, before it validates anything.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package processor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lixenwraith/chess/internal/server/chess"
|
"github.com/lixenwraith/chess/internal/server/chess"
|
||||||
@@ -25,6 +27,33 @@ func TestRulesCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotateLine(t *testing.T) {
|
||||||
|
line, err := chess.Replay(chess.StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fens := make([]string, 6)
|
||||||
|
for i := range fens {
|
||||||
|
fens[i] = line.Position(i).FEN()
|
||||||
|
}
|
||||||
|
moves := []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"}
|
||||||
|
if got := strings.Join(notateLine(fens, moves), " "); got != "e4 e5 Nf3 Nc6 Bb5" {
|
||||||
|
t.Errorf("SAN = %q", got)
|
||||||
|
}
|
||||||
|
// A ply the core cannot notate leaves only its own entry empty.
|
||||||
|
broken := append([]string(nil), fens...)
|
||||||
|
broken[2] = "not a fen"
|
||||||
|
if got := notateLine(broken, moves); !reflect.DeepEqual(got, []string{"e4", "e5", "", "Nc6", "Bb5"}) {
|
||||||
|
t.Errorf("SAN with a broken position = %q", got)
|
||||||
|
}
|
||||||
|
if got := notateLine(nil, nil); len(got) != 0 {
|
||||||
|
t.Errorf("SAN of no moves = %q", got)
|
||||||
|
}
|
||||||
|
if got := notateLine(fens[:1], moves[:2]); !reflect.DeepEqual(got, []string{"e4", ""}) {
|
||||||
|
t.Errorf("SAN short of positions = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdjudicateDraw(t *testing.T) {
|
func TestAdjudicateDraw(t *testing.T) {
|
||||||
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
||||||
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
|
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -172,10 +176,13 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game
|
|||||||
InitialFEN: record.InitialFEN,
|
InitialFEN: record.InitialFEN,
|
||||||
Result: record.Result,
|
Result: record.Result,
|
||||||
PGNResult: chess.ResultToken(record.Result),
|
PGNResult: chess.ResultToken(record.Result),
|
||||||
|
Termination: record.Termination,
|
||||||
StartTimeUTC: record.StartTimeUTC,
|
StartTimeUTC: record.StartTimeUTC,
|
||||||
EndTimeUTC: record.EndTimeUTC,
|
EndTimeUTC: record.EndTimeUTC,
|
||||||
MoveCount: record.MoveCount,
|
MoveCount: record.MoveCount,
|
||||||
FinalFEN: record.FinalFEN,
|
FinalFEN: record.FinalFEN,
|
||||||
|
Origin: record.Origin(),
|
||||||
|
Event: record.Event,
|
||||||
Players: playersResponse(record.GameRecord),
|
Players: playersResponse(record.GameRecord),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ func TestUserGamesCursorAndFilters(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" {
|
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" ||
|
||||||
|
page.Games[0].Termination != "checkmate" {
|
||||||
t.Fatalf("finished games = %+v", page.Games)
|
t.Fatalf("finished games = %+v", page.Games)
|
||||||
}
|
}
|
||||||
for _, bad := range []UserGamesOptions{
|
for _, bad := range []UserGamesOptions{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/server/mail"
|
||||||
|
"github.com/lixenwraith/chess/internal/server/storage"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PasswordResetTTL is how long a reset link works.
|
||||||
|
PasswordResetTTL = time.Hour
|
||||||
|
resetTokenBytes = 32
|
||||||
|
mailSendTimeout = 20 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrMailDisabled reports that no mail transport is configured, so
|
||||||
|
// password recovery is off.
|
||||||
|
ErrMailDisabled = errors.New("password recovery is not configured")
|
||||||
|
// ErrMailFailed reports a delivery failure to the relay.
|
||||||
|
ErrMailFailed = errors.New("could not send the email")
|
||||||
|
// ErrResetInvalid reports an unknown, used, or expired reset token.
|
||||||
|
ErrResetInvalid = errors.New("invalid or expired reset link")
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailer is the configured transport and the client URL reset links open.
|
||||||
|
type mailer struct {
|
||||||
|
sender mail.Sender
|
||||||
|
publicURL *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMailer configures password recovery: sender delivers the email and
|
||||||
|
// publicURL is the web client's address, which the link opens with
|
||||||
|
// ?reset=<token>. A nil sender turns recovery off.
|
||||||
|
func (s *Service) SetMailer(sender mail.Sender, publicURL string) error {
|
||||||
|
if sender == nil {
|
||||||
|
s.mail = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(publicURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("public URL %q must be absolute (https://host/chess/web-client/)", publicURL)
|
||||||
|
}
|
||||||
|
s.mail = &mailer{sender: sender, publicURL: u}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailHealth reports "ok" when password recovery can send mail, otherwise
|
||||||
|
// "disabled".
|
||||||
|
func (s *Service) MailHealth() string {
|
||||||
|
if s.mail == nil || s.store == nil {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResetToken returns a fresh reset token and the hash stored for it.
|
||||||
|
func NewResetToken() (token string, hash []byte, err error) {
|
||||||
|
raw := make([]byte, resetTokenBytes)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
return token, hashResetToken(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashResetToken(token string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetLink is the web client URL that opens the password form for token.
|
||||||
|
func ResetLink(publicURL *url.URL, token string) string {
|
||||||
|
u := *publicURL
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("reset", token)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestPasswordReset emails a reset link to the account named by
|
||||||
|
// identifier (username or email) when it has an email address. It reports
|
||||||
|
// success for unknown accounts and accounts without an email, so the request
|
||||||
|
// reveals nothing about who is registered; the caller sees the same answer.
|
||||||
|
func (s *Service) RequestPasswordReset(identifier string) error {
|
||||||
|
if s.store == nil {
|
||||||
|
return ErrStorageDisabled
|
||||||
|
}
|
||||||
|
if s.mail == nil {
|
||||||
|
return ErrMailDisabled
|
||||||
|
}
|
||||||
|
identifier = strings.ToLower(strings.TrimSpace(identifier))
|
||||||
|
var record *storage.UserRecord
|
||||||
|
var err error
|
||||||
|
if strings.Contains(identifier, "@") {
|
||||||
|
record, err = s.store.GetUserByEmail(identifier)
|
||||||
|
} else {
|
||||||
|
record, err = s.store.GetUserByUsername(identifier)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
slog.Info("password reset requested for an unknown account")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
if record.Email == "" {
|
||||||
|
slog.Info("password reset requested for an account without email", "user_id", record.UserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token, hash, err := NewResetToken()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate reset token: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := s.store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||||
|
UserID: record.UserID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(PasswordResetTTL),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("%w: store reset: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := ResetLink(s.mail.publicURL, token)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), mailSendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
err = s.mail.sender.Send(ctx, mail.Message{
|
||||||
|
To: record.Email,
|
||||||
|
Subject: "Reset your chess password",
|
||||||
|
Body: fmt.Sprintf(`Hello %s,
|
||||||
|
|
||||||
|
Someone asked to reset the password of your chess account at %s.
|
||||||
|
If it was you, open this link within the next hour:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
If you did not ask for this, ignore this message: your password stays as
|
||||||
|
it is and nobody else can use the link without this email.
|
||||||
|
`, record.Username, s.mail.publicURL.Host, link),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("password reset email failed", "user_id", record.UserID, "error", err)
|
||||||
|
return ErrMailFailed
|
||||||
|
}
|
||||||
|
slog.Info("password reset email sent", "user_id", record.UserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword sets a new password for the account whose pending reset
|
||||||
|
// token this is, ends its other sessions, and signs the user in. The token
|
||||||
|
// works once and only until its expiry.
|
||||||
|
func (s *Service) ResetPassword(token, password string) (*User, string, error) {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil, "", ErrStorageDisabled
|
||||||
|
}
|
||||||
|
if raw, err := base64.RawURLEncoding.DecodeString(token); err != nil || len(raw) != resetTokenBytes {
|
||||||
|
return nil, "", ErrResetInvalid
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
userID, err := s.store.ConsumePasswordReset(hashResetToken(token), now)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, "", ErrResetInvalid
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("%w: consume reset: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := s.hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAuthBusy) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.store.ResetUserPassword(userID, passwordHash); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, "", ErrResetInvalid
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("%w: reset password: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
record, err := s.store.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
sessionID := uuid.NewString()
|
||||||
|
if err := s.store.CreateSession(storage.SessionRecord{
|
||||||
|
SessionID: sessionID, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(SessionTTL),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
slog.Info("password reset completed", "user_id", userID)
|
||||||
|
return &User{
|
||||||
|
UserID: record.UserID, Username: record.Username, Email: record.Email, CreatedAt: record.CreatedAt,
|
||||||
|
}, sessionID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/chess/internal/server/mail"
|
||||||
|
"github.com/lixenwraith/chess/internal/server/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
var linkPattern = regexp.MustCompile(`https://example\.test/chess/web-client/\?reset=([A-Za-z0-9_-]{43})`)
|
||||||
|
|
||||||
|
func TestPasswordResetFlow(t *testing.T) {
|
||||||
|
svc := newPersistentTestService(t)
|
||||||
|
if svc.MailHealth() != "disabled" {
|
||||||
|
t.Fatal("mail reported before configuration")
|
||||||
|
}
|
||||||
|
if err := svc.RequestPasswordReset("alice"); !errors.Is(err, ErrMailDisabled) {
|
||||||
|
t.Fatalf("recovery without mail: %v", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "mail")
|
||||||
|
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "/chess/"); err == nil {
|
||||||
|
t.Fatal("relative public URL accepted")
|
||||||
|
}
|
||||||
|
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "https://example.test/chess/web-client/"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if svc.MailHealth() != "ok" {
|
||||||
|
t.Fatal("mail not reported ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, sessionID, err := svc.RegisterUser("alice", "Alice@Example.test", "Password1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown accounts and accounts without email get the same answer and no mail.
|
||||||
|
for _, id := range []string{"nobody", "nobody@example.test", "bob"} {
|
||||||
|
if err := svc.RequestPasswordReset(id); err != nil {
|
||||||
|
t.Errorf("recover %q: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
||||||
|
t.Fatalf("mail sent for unknown accounts: %d files", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// By email, case-insensitively; then by username, which replaces the link.
|
||||||
|
if err := svc.RequestPasswordReset("ALICE@example.test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := lastMailToken(t, dir)
|
||||||
|
if err := svc.RequestPasswordReset("alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := lastMailToken(t, dir)
|
||||||
|
if token == first {
|
||||||
|
t.Fatal("second request reused the token")
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ResetPassword(first, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
||||||
|
t.Errorf("replaced token: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "x", strings.Repeat("A", 43), token + "A"} {
|
||||||
|
if _, _, err := svc.ResetPassword(bad, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
||||||
|
t.Errorf("token %q: %v", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset, newSession, err := svc.ResetPassword(token, "Password2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reset.UserID != user.UserID || reset.Username != "alice" || newSession == "" || newSession == sessionID {
|
||||||
|
t.Fatalf("reset returned %+v, session %q", reset, newSession)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ResetPassword(token, "Password3"); !errors.Is(err, ErrResetInvalid) {
|
||||||
|
t.Errorf("token reused: %v", err)
|
||||||
|
}
|
||||||
|
// The old session is gone, the new one and the new password work.
|
||||||
|
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, sessionID)); err == nil {
|
||||||
|
t.Error("old session still valid")
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, newSession)); err != nil {
|
||||||
|
t.Errorf("new session invalid: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.AuthenticateUser("alice", "Password1"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Errorf("old password: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.AuthenticateUser("alice", "Password2"); err != nil {
|
||||||
|
t.Errorf("new password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expired link is refused and the cleanup removes it.
|
||||||
|
expired, hash, err := NewResetToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
past := time.Now().UTC().Add(-time.Minute)
|
||||||
|
if err := svc.store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||||
|
UserID: user.UserID, TokenHash: hash, CreatedAt: past.Add(-time.Hour), ExpiresAt: past,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ResetPassword(expired, "Password4"); !errors.Is(err, ErrResetInvalid) {
|
||||||
|
t.Errorf("expired token: %v", err)
|
||||||
|
}
|
||||||
|
if deleted, err := svc.store.DeleteExpiredPasswordResets(); err != nil || deleted != 1 {
|
||||||
|
t.Errorf("expired resets deleted = %d, %v", deleted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastMailToken(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("no mail in %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entries[len(entries)-1].Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, "To: alice@example.test\r\n") || !strings.Contains(text, "Subject: Reset your chess password\r\n") {
|
||||||
|
t.Fatalf("mail = %q", text)
|
||||||
|
}
|
||||||
|
m := linkPattern.FindStringSubmatch(text)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no reset link in %q", text)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustToken(t *testing.T, svc *Service, userID, sessionID string) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := svc.GenerateUserToken(userID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ type Service struct {
|
|||||||
|
|
||||||
integrityMode IntegrityMode
|
integrityMode IntegrityMode
|
||||||
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
|
||||||
|
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
|
||||||
@@ -91,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) {
|
||||||
@@ -193,6 +202,11 @@ func (s *Service) cleanupExpired() {
|
|||||||
} else if deleted > 0 {
|
} else if deleted > 0 {
|
||||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||||
}
|
}
|
||||||
|
if deleted, err := s.store.DeleteExpiredPasswordResets(); err != nil {
|
||||||
|
slog.Error("cleanup failed to delete expired password resets", "error", err)
|
||||||
|
} else if deleted > 0 {
|
||||||
|
slog.Info("cleanup deleted expired password resets", "count", deleted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.cleanupGames(now)
|
s.cleanupGames(now)
|
||||||
s.checkIntegrity(now)
|
s.checkIntegrity(now)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordResetRecord is a pending password reset: the SHA-256 of the token
|
||||||
|
// sent to the account's email, and when it stops working.
|
||||||
|
type PasswordResetRecord struct {
|
||||||
|
UserID string
|
||||||
|
TokenHash []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePasswordReset stores a reset for the user, replacing any pending one,
|
||||||
|
// so only the latest link works. It returns sql.ErrNoRows for an unknown
|
||||||
|
// user.
|
||||||
|
func (s *Store) CreatePasswordReset(record PasswordResetRecord) error {
|
||||||
|
if !validUUID(record.UserID) {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if len(record.TokenHash) != 32 {
|
||||||
|
return errors.New("token hash must be 32 bytes")
|
||||||
|
}
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
result, err := s.db.ExecContext(ctx, `INSERT INTO password_resets (user_id, token_hash, created_at, expires_at)
|
||||||
|
SELECT user_id, $2, $3, $4 FROM users WHERE user_id = $1
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
token_hash = excluded.token_hash,
|
||||||
|
created_at = excluded.created_at,
|
||||||
|
expires_at = excluded.expires_at`,
|
||||||
|
record.UserID, record.TokenHash, record.CreatedAt, record.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create password reset: %w", err)
|
||||||
|
}
|
||||||
|
if rows, err := result.RowsAffected(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
slog.Debug("storage password reset created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumePasswordReset deletes the reset matching tokenHash and returns its
|
||||||
|
// user, or sql.ErrNoRows when none is pending or it has expired. A token
|
||||||
|
// works once.
|
||||||
|
func (s *Store) ConsumePasswordReset(tokenHash []byte, now time.Time) (string, error) {
|
||||||
|
if len(tokenHash) != 32 {
|
||||||
|
return "", sql.ErrNoRows
|
||||||
|
}
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
var userID string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`DELETE FROM password_resets WHERE token_hash = $1 AND expires_at > $2 RETURNING user_id`,
|
||||||
|
tokenHash, now.UTC()).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExpiredPasswordResets removes resets past their expiry.
|
||||||
|
func (s *Store) DeleteExpiredPasswordResets() (int64, error) {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM password_resets WHERE expires_at < $1`, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
deleted, err := result.RowsAffected()
|
||||||
|
if err == nil && deleted > 0 {
|
||||||
|
slog.Debug("storage expired password resets deleted", "count", deleted)
|
||||||
|
}
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetUserPassword sets a new password hash and ends the user's sessions in
|
||||||
|
// one transaction: whoever held the old password is signed out everywhere.
|
||||||
|
// It returns sql.ErrNoRows for an unknown user.
|
||||||
|
func (s *Store) ResetUserPassword(userID, passwordHash string) error {
|
||||||
|
if !validUUID(userID) {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE user_id = $1`, userID, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update password: %w", err)
|
||||||
|
}
|
||||||
|
if rows, err := result.RowsAffected(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
|
||||||
|
return fmt.Errorf("end sessions: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM password_resets WHERE user_id = $1`, userID); err != nil {
|
||||||
|
return fmt.Errorf("clear password resets: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Debug("storage password reset", "user_id", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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 = 3
|
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.
|
||||||
@@ -213,7 +227,39 @@ ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK (
|
|||||||
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
|
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
|
||||||
OR (concession_result = 'draw' AND concession_termination = 'agreement'))));
|
OR (concession_result = 'draw' AND concession_termination = 'agreement'))));
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
// v4: password recovery. One pending reset per account; the token the
|
||||||
|
// email carries is never stored, only its SHA-256, and it expires after
|
||||||
|
// an hour. Consuming a token deletes the row.
|
||||||
|
`
|
||||||
|
CREATE TABLE password_resets (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users (user_id) ON DELETE CASCADE,
|
||||||
|
token_hash bytea NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT password_resets_token_hash_key UNIQUE (token_hash),
|
||||||
|
CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32)
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownedTables lists every table created by migrations, children first.
|
// ownedTables lists every table created by migrations, children first.
|
||||||
var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"}
|
var ownedTables = []string{"moves", "games", "password_resets", "sessions", "users", "schema_version"}
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,86 +3,229 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Chess Game</title>
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Chess</title>
|
||||||
|
<!-- 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>
|
<body data-mode="live">
|
||||||
<div class="outer-container">
|
<div class="app">
|
||||||
<div class="container">
|
<header class="topbar">
|
||||||
<main>
|
<div class="brand" aria-label="Chess">
|
||||||
<div class="game-area">
|
<svg class="brand-mark" viewBox="41 34 430 430" aria-hidden="true" focusable="false">
|
||||||
<div class="board-container">
|
<defs>
|
||||||
<div class="board-wrapper">
|
<linearGradient id="brand-piece" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||||
<div class="coordinates top">
|
<stop offset="0%" stop-color="#C8CDD6"/>
|
||||||
<span>a</span><span>b</span><span>c</span><span>d</span>
|
<stop offset="45%" stop-color="#8B929E"/>
|
||||||
<span>e</span><span>f</span><span>g</span><span>h</span>
|
<stop offset="100%" stop-color="#4F545D"/>
|
||||||
</div>
|
</linearGradient>
|
||||||
<div class="coordinates left">
|
</defs>
|
||||||
<span>8</span><span>7</span><span>6</span><span>5</span>
|
<g fill="url(#brand-piece)" stroke="#14161A" stroke-width="4" stroke-linejoin="round">
|
||||||
<span>4</span><span>3</span><span>2</span><span>1</span>
|
<path d="M136 450Q256 465 376 450L346 410Q256 420 166 410Z"/>
|
||||||
</div>
|
<path d="M166 410Q256 420 346 410L316 370Q256 380 196 370Z"/>
|
||||||
<div id="board"></div>
|
<path d="M196 370Q256 380 316 370C300 290 286 230 282 205H230C226 230 212 290 196 370Z"/>
|
||||||
</div>
|
</g>
|
||||||
</div>
|
<path d="M330 90Q265 96 230 120Q265 144 335 150Z" fill="#111318"/>
|
||||||
</div>
|
<g stroke="#F0F2F5" stroke-width="6" stroke-linecap="round">
|
||||||
|
<path d="M263.17 85V155M299.33 85V155"/>
|
||||||
<aside class="info-panel">
|
</g>
|
||||||
<div class="status-indicators">
|
<g fill="url(#brand-piece)" stroke="#14161A" stroke-width="4" stroke-linejoin="round">
|
||||||
<div class="indicator" id="server-indicator" data-tooltip="Server">
|
<path d="M226 200C150 180 150 70 256 40C290 50 315 65 330 90Q265 96 230 120Q265 144 335 150C335 170 315 190 286 200Z"/>
|
||||||
<span class="light" data-status="unknown">●</span>
|
<rect x="216" y="195" width="80" height="16" rx="8"/>
|
||||||
</div>
|
</g>
|
||||||
<div class="indicator" id="storage-indicator" data-tooltip="Storage">
|
|
||||||
<span class="light" data-status="unknown">●</span>
|
|
||||||
</div>
|
|
||||||
<div class="indicator" id="turn-indicator" data-tooltip="Turn">
|
|
||||||
<span class="light turn-light" data-status="white">●</span>
|
|
||||||
</div>
|
|
||||||
<div class="indicator auth-indicator" id="auth-indicator" data-tooltip="Auth">
|
|
||||||
<span class="light" data-status="anonymous">●</span>
|
|
||||||
</div>
|
|
||||||
<div class="error-flash-overlay" id="error-flash-overlay">
|
|
||||||
<div class="error-flash-message" id="error-flash-message"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button id="new-game-btn" class="btn btn-primary">New</button>
|
|
||||||
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
|
|
||||||
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
|
|
||||||
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="move-history-container">
|
|
||||||
<button class="copy-btn" id="copy-history" title="Copy PGN">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="brand-name">Chess</span>
|
||||||
|
</div>
|
||||||
|
<div class="health" aria-label="Server status">
|
||||||
|
<span class="dot" id="server-dot" data-status="unknown" title="Server: unknown" role="img" aria-label="Server: unknown"></span>
|
||||||
|
<span class="dot" id="storage-dot" data-status="unknown" title="Storage: unknown" role="img" aria-label="Storage: unknown"></span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-spacer"></div>
|
||||||
|
<button id="games-btn" class="btn btn-ghost btn-sm" type="button" title="Stored games">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span>My games</span>
|
||||||
|
</button>
|
||||||
|
<button id="auth-btn" class="btn btn-ghost btn-sm" type="button" data-status="anonymous" title="Sign in or register">
|
||||||
|
<span class="dot" id="auth-dot" data-status="anonymous" aria-hidden="true"></span>
|
||||||
|
<span id="auth-label">Sign in</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section class="board-area" aria-label="Board">
|
||||||
|
<div class="board-wrapper">
|
||||||
|
<div class="coordinates files" aria-hidden="true"></div>
|
||||||
|
<div class="coordinates ranks" aria-hidden="true"></div>
|
||||||
|
<div id="board" role="grid" aria-label="Chess board"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toast-host"><div id="toast" class="toast" role="status" aria-live="polite"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel" aria-label="Game panel">
|
||||||
|
<!-- Live game -->
|
||||||
|
<div class="pane pane-live" id="pane-live">
|
||||||
|
<div class="turn-strip" id="turn-strip">
|
||||||
|
<span class="dot turn-dot" id="turn-dot" data-status="idle" aria-hidden="true"></span>
|
||||||
|
<span class="turn-text" id="turn-text">No game</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls" role="group" aria-label="Game controls">
|
||||||
|
<button id="new-game-btn" class="btn btn-primary" type="button" title="New game">New</button>
|
||||||
|
<button id="undo-btn" class="btn btn-secondary" type="button" disabled>Undo</button>
|
||||||
|
<button id="draw-btn" class="btn btn-secondary" type="button" title="Offer a draw" disabled>Draw</button>
|
||||||
|
<button id="resign-btn" class="btn btn-secondary" type="button" title="Resign this game" disabled>Resign</button>
|
||||||
|
</div>
|
||||||
|
<div class="moves" id="live-moves-box">
|
||||||
|
<div class="move-grid" id="live-moves" aria-label="Moves"></div>
|
||||||
|
<p class="moves-empty" id="live-moves-empty">Start a game to play Stockfish, or open <strong>My games</strong> to replay one.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" role="group" aria-label="Export">
|
||||||
|
<button id="copy-fen-btn" class="btn btn-tool" type="button" title="Copy the current position as FEN" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>FEN</span>
|
||||||
|
</button>
|
||||||
|
<button id="copy-pgn-btn" class="btn btn-tool" type="button" title="Copy the game as PGN" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>PGN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-live-btn" class="btn btn-tool" type="button" title="Replay this game from the start" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||||||
|
<span>Replay</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="move-history" class="move-history">
|
|
||||||
<div class="move-grid" id="move-grid"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Replay -->
|
||||||
|
<div class="pane pane-replay" id="pane-replay">
|
||||||
|
<div class="replay-head">
|
||||||
|
<div class="replay-title-row">
|
||||||
|
<span class="badge" id="replay-badge">Replay</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>
|
||||||
|
</div>
|
||||||
|
<div class="replay-players" id="replay-players"></div>
|
||||||
|
<div class="replay-meta" id="replay-meta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="moves" id="replay-moves-box">
|
||||||
|
<div class="move-grid move-grid-replay" id="replay-moves" aria-label="Moves"></div>
|
||||||
|
<p class="moves-empty" id="replay-moves-empty">Loading…</p>
|
||||||
|
</div>
|
||||||
|
<div class="replay-nav" role="group" aria-label="Replay controls">
|
||||||
|
<button id="replay-first" class="btn btn-nav" type="button" title="First position (Home)" aria-label="First position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5v14M18 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-prev" class="btn btn-nav" type="button" title="Previous move (←)" aria-label="Previous move">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-play" class="btn btn-nav btn-play" type="button" title="Autoplay (Space)" aria-label="Autoplay" aria-pressed="false">
|
||||||
|
<svg class="icon icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||||||
|
<svg class="icon icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-next" class="btn btn-nav" type="button" title="Next move (→)" aria-label="Next move">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="replay-last" class="btn btn-nav" type="button" title="Last position (End)" aria-label="Last position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 5v14M6 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<label class="speed" title="Autoplay speed">
|
||||||
|
<span class="visually-hidden">Autoplay speed</span>
|
||||||
|
<select id="replay-speed">
|
||||||
|
<option value="2000">0.5×</option>
|
||||||
|
<option value="1000" selected>1×</option>
|
||||||
|
<option value="500">2×</option>
|
||||||
|
<option value="250">4×</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="replay-flip" class="btn btn-nav" type="button" title="Flip the board (F)" aria-label="Flip the board" aria-pressed="false">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v12M7 16l-3-3M7 16l3-3M17 20V8M17 8l-3 3M17 8l3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" role="group" aria-label="Export at this position">
|
||||||
|
<button id="replay-copy-fen" class="btn btn-tool" type="button" title="Copy the FEN of this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>FEN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-copy-pgn" class="btn btn-tool" type="button" title="Copy the PGN up to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>PGN</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-download" class="btn btn-tool" type="button" title="Download the PGN up to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 19h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button id="replay-link" class="btn btn-tool" type="button" title="Copy a link to this position">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span>Link</span>
|
||||||
|
</button>
|
||||||
|
<!-- 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 id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My games drawer -->
|
||||||
|
<div id="games-overlay" class="modal-overlay">
|
||||||
|
<div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="games-title">My games</h2>
|
||||||
|
<button id="games-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">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="games-filters" id="games-filters">
|
||||||
|
<div class="segmented" role="group" aria-label="Status">
|
||||||
|
<button type="button" class="seg active" data-filter="status" data-value="">All</button>
|
||||||
|
<button type="button" class="seg" data-filter="status" data-value="ongoing">Ongoing</button>
|
||||||
|
<button type="button" class="seg" data-filter="status" data-value="finished">Finished</button>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="group" aria-label="Your colour">
|
||||||
|
<button type="button" class="seg active" data-filter="color" data-value="">Any</button>
|
||||||
|
<button type="button" class="seg" data-filter="color" data-value="white">White</button>
|
||||||
|
<button type="button" class="seg" data-filter="color" data-value="black">Black</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="games-body">
|
||||||
|
<section class="games-section" id="account-games-section">
|
||||||
|
<h3 class="games-heading" id="account-games-heading">Your games</h3>
|
||||||
|
<p class="games-note" id="games-signin-note">Sign in to keep your games and replay them here. Games played anonymously are kept for 24 hours.</p>
|
||||||
|
<ul class="games-list" id="games-list"></ul>
|
||||||
|
<p class="games-status" id="games-status" role="status"></p>
|
||||||
|
<button id="games-more" class="btn btn-secondary btn-block" type="button">Load more</button>
|
||||||
|
</section>
|
||||||
|
<section class="games-section" id="local-games-section">
|
||||||
|
<h3 class="games-heading">On this device</h3>
|
||||||
|
<p class="games-note">Games played here without signing in; the server keeps them for 24 hours after the last move.</p>
|
||||||
|
<ul class="games-list" id="local-games-list"></ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth Modal -->
|
<!-- Auth Modal -->
|
||||||
<div id="auth-modal-overlay" class="modal-overlay">
|
<div id="auth-modal-overlay" class="modal-overlay">
|
||||||
<div class="modal auth-modal">
|
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||||
<div class="auth-tabs">
|
<h2 id="auth-title" class="visually-hidden">Account</h2>
|
||||||
<button class="auth-tab active" data-tab="login">Login</button>
|
<div class="auth-tabs" role="tablist">
|
||||||
<button class="auth-tab" data-tab="register">Register</button>
|
<button class="auth-tab active" type="button" role="tab" data-tab="login" aria-selected="true">Sign in</button>
|
||||||
|
<button class="auth-tab" type="button" role="tab" data-tab="register" aria-selected="false">Register</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="auth-modal-message" class="modal-message"></div>
|
<div id="auth-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<div id="login-form" class="auth-form">
|
<div id="login-form" class="auth-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="login-identifier">Username or Email</label>
|
<label for="login-identifier">Username or email</label>
|
||||||
<input type="text" id="login-identifier" autocomplete="username">
|
<input type="text" id="login-identifier" autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -90,13 +233,25 @@
|
|||||||
<input type="password" id="login-password" autocomplete="current-password">
|
<input type="password" id="login-password" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="login-submit-btn" class="btn btn-primary">Login</button>
|
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
|
||||||
<button id="auth-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-aside" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="recover-form" class="auth-form hidden">
|
||||||
|
<p class="auth-help">Enter your username or email. If the account has an email address, a link to choose a new password is sent to it; the link works for an hour.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="recover-identifier">Username or email</label>
|
||||||
|
<input type="text" id="recover-identifier" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="recover-submit-btn" class="btn btn-primary" type="button">Send reset link</button>
|
||||||
|
<button id="recover-back-btn" class="btn btn-secondary" type="button">Back</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register Form -->
|
<div id="register-form" class="auth-form hidden">
|
||||||
<div id="register-form" class="auth-form" style="display: none;">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="register-username">Username</label>
|
<label for="register-username">Username</label>
|
||||||
<input type="text" id="register-username" autocomplete="username">
|
<input type="text" id="register-username" autocomplete="username">
|
||||||
@@ -106,17 +261,62 @@
|
|||||||
<input type="email" id="register-email" autocomplete="email">
|
<input type="email" id="register-email" autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="register-password">Password (min 8 chars)</label>
|
<label for="register-password">Password (8+ characters, a letter and a number)</label>
|
||||||
<input type="password" id="register-password" autocomplete="new-password">
|
<input type="password" id="register-password" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="register-submit-btn" class="btn btn-primary">Register</button>
|
<button id="register-submit-btn" class="btn btn-primary" type="button">Register</button>
|
||||||
<button id="auth-cancel-btn-2" class="btn btn-secondary">Cancel</button>
|
<button id="auth-cancel-btn-2" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>) -->
|
||||||
|
<div id="reset-overlay" class="modal-overlay">
|
||||||
|
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
|
||||||
|
<h2 id="reset-title">Choose a new password</h2>
|
||||||
|
<div id="reset-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="reset-password">New password (8+ characters, a letter and a number)</label>
|
||||||
|
<input type="password" id="reset-password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="reset-password-2">Repeat the password</label>
|
||||||
|
<input type="password" id="reset-password-2" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="reset-submit-btn" class="btn btn-primary" type="button">Set password</button>
|
||||||
|
<button id="reset-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Modal: shown instead of the login modal while signed in -->
|
<!-- Account Modal: shown instead of the login modal while signed in -->
|
||||||
<div id="account-overlay" class="modal-overlay">
|
<div id="account-overlay" class="modal-overlay">
|
||||||
<div class="modal account-modal" role="dialog" aria-modal="true" aria-labelledby="account-title">
|
<div class="modal account-modal" role="dialog" aria-modal="true" aria-labelledby="account-title">
|
||||||
@@ -124,8 +324,8 @@
|
|||||||
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
|
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="logout-btn" class="btn btn-primary">Log out</button>
|
<button id="logout-btn" class="btn btn-primary" type="button">Sign out</button>
|
||||||
<button id="account-cancel-btn" class="btn btn-secondary">Close</button>
|
<button id="account-cancel-btn" class="btn btn-secondary" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<details class="danger-zone" id="delete-account">
|
<details class="danger-zone" id="delete-account">
|
||||||
<summary>Delete account</summary>
|
<summary>Delete account</summary>
|
||||||
@@ -137,40 +337,40 @@
|
|||||||
<input type="password" id="delete-password" autocomplete="current-password">
|
<input type="password" id="delete-password" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="delete-account-btn" class="btn btn-danger">Delete account</button>
|
<button id="delete-account-btn" class="btn btn-danger" type="button">Delete account</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overlay Modal -->
|
<!-- New Game Modal -->
|
||||||
<div id="modal-overlay" class="modal-overlay">
|
<div id="modal-overlay" class="modal-overlay">
|
||||||
<div class="modal">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="new-game-title">
|
||||||
<h2>New Game</h2>
|
<h2 id="new-game-title">New game</h2>
|
||||||
<div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
<div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||||
<div class="form-group form-row">
|
<div class="form-group form-row">
|
||||||
<span class="group-label" id="player-color-label">Your Color</span>
|
<span class="group-label" id="player-color-label">Your colour</span>
|
||||||
<div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
|
<div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
|
||||||
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
|
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
|
||||||
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
|
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="computer-level">Computer Level: <span id="level-value">10</span></label>
|
<label for="computer-level">Computer level: <span id="level-value">10</span></label>
|
||||||
<input type="range" id="computer-level" min="0" max="20" value="10">
|
<input type="range" id="computer-level" min="0" max="20" value="10">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="search-time">Search Time (ms): <span id="time-value">1000</span></label>
|
<label for="search-time">Search time: <span id="time-value">1000</span> ms</label>
|
||||||
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
|
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="starting-fen">Starting Position (FEN)</label>
|
<label for="starting-fen">Starting position (FEN)</label>
|
||||||
<textarea id="starting-fen" class="fen-input" rows="2"
|
<textarea id="starting-fen" class="fen-input" rows="2" spellcheck="false"
|
||||||
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
|
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
|
<button id="start-game-btn" class="btn btn-primary" type="button">Start game</button>
|
||||||
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,8 +380,8 @@
|
|||||||
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
|
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
|
||||||
<h2 id="resign-title">Resign this game?</h2>
|
<h2 id="resign-title">Resign this game?</h2>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
|
<button id="resign-confirm-btn" class="btn btn-primary" type="button">Resign</button>
|
||||||
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="resign-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,13 +391,13 @@
|
|||||||
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
|
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
|
||||||
<h2 id="promotion-title">Promote to</h2>
|
<h2 id="promotion-title">Promote to</h2>
|
||||||
<div class="promotion-choices">
|
<div class="promotion-choices">
|
||||||
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
|
<button class="promotion-choice" type="button" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
|
||||||
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
|
<button class="promotion-choice" type="button" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
|
||||||
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
|
<button class="promotion-choice" type="button" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
|
||||||
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
|
<button class="promotion-choice" type="button" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
|
<button id="promotion-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+58
-4
@@ -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.
|
||||||
|
|
||||||
@@ -38,9 +48,12 @@ The server runs with `-dev`, debug logging, HTTP request logging, and relaxed ra
|
|||||||
It drops the test tables on exit, so it's preferred for clean testing.
|
It drops the test tables on exit, so it's preferred for clean testing.
|
||||||
Can be used for all the tests.
|
Can be used for all the tests.
|
||||||
|
|
||||||
Logging can be adjusted without editing the script:
|
Logging can be adjusted without editing the script; `WEB_PORT` also serves
|
||||||
|
the embedded web client and `MAIL_DIR` turns on password recovery with the
|
||||||
|
email written to that directory (both used by the end-to-end suite):
|
||||||
```bash
|
```bash
|
||||||
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
|
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
|
||||||
|
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh bin/chess-server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pre-configured Users
|
### Pre-configured Users
|
||||||
@@ -83,6 +96,7 @@ curl -X POST http://localhost:8080/api/games \
|
|||||||
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions |
|
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions |
|
||||||
| Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing |
|
| Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing |
|
||||||
| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, concessions and claim control, server version, hand-deleted game row |
|
| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, concessions and claim control, server version, hand-deleted game row |
|
||||||
|
| Web client (end-to-end) | `e2e/` | Playwright in Chromium against the embedded web client: play, My games, replay controls and keyboard, autoplay, FEN/PGN export and download compared with the API, deep links, device list |
|
||||||
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
||||||
|
|
||||||
## 1. API Functionality Tests (`test-api.sh`)
|
## 1. API Functionality Tests (`test-api.sh`)
|
||||||
@@ -216,3 +230,43 @@ unknown route answered with `NOT_FOUND`, `/health` reporting the build, and a
|
|||||||
game whose row is deleted with
|
game whose row is deleted with
|
||||||
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
|
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
|
||||||
stays healthy).
|
stays healthy).
|
||||||
|
|
||||||
|
## Web client end-to-end tests (`e2e/`)
|
||||||
|
|
||||||
|
Playwright drives the embedded web client in Chromium against a running
|
||||||
|
server with PostgreSQL and Stockfish: it plays a short game, lists it under
|
||||||
|
My games, replays it with the buttons and the keyboard, autoplays to the end,
|
||||||
|
compares the FEN and PGN it exports (copy and download) with the API, follows
|
||||||
|
a deep link to a checkmate, steps back through the browser history, replays
|
||||||
|
an anonymous game from the device list, checks that a phone-sized viewport
|
||||||
|
holds the board and the controls without scrolling, and resets a forgotten
|
||||||
|
password through the link in the emailed message (needs `MAIL_DIR`).
|
||||||
|
|
||||||
|
Requirements: Node.js 20+ and a Chromium that Playwright can run.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: the test server with the web client and mail to a directory
|
||||||
|
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh bin/chess-server
|
||||||
|
|
||||||
|
# Terminal 2, once
|
||||||
|
cd test/e2e && npm install && npx playwright install chromium
|
||||||
|
|
||||||
|
# Terminal 2: run
|
||||||
|
CHESS_E2E_MAIL_DIR=/tmp/chess-mail npm test # or, from the repository root: make test-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
- `CHESS_E2E_WEB` (default `http://localhost:9090`): the web client's origin
|
||||||
|
- `CHESS_E2E_API` (default `http://localhost:8080`): the API's origin, used
|
||||||
|
to register users, create games and fetch what the client should export
|
||||||
|
- `CHESS_E2E_CHROMIUM`: path to a Chromium binary when
|
||||||
|
`npx playwright install` is not possible (an already installed browser)
|
||||||
|
- `CHESS_E2E_MAIL_DIR`: the server's `-mail-dir` (`MAIL_DIR` of the test
|
||||||
|
server); the password-reset test reads the link from the newest file and
|
||||||
|
is skipped when unset
|
||||||
|
|
||||||
|
Each test registers its own user or plays anonymously, so the suite can be
|
||||||
|
repeated against the same server. The engine plays at level 0 with a 100 ms
|
||||||
|
search, so a run takes about ten seconds. On failure Playwright keeps a trace
|
||||||
|
under `test/e2e/test-results/`; open it with `npx playwright show-trace`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "chess-web-e2e",
|
||||||
|
"private": true,
|
||||||
|
"description": "Playwright end-to-end tests for the chess web client (see test/README.md)",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.57.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Playwright configuration for the web client's end-to-end tests. The server
|
||||||
|
// must already run with -serve and PostgreSQL (test/run-test-server.sh with
|
||||||
|
// WEB_PORT, or any -dev -serve server): CHESS_E2E_WEB names the client's
|
||||||
|
// origin and CHESS_E2E_API the API's. CHESS_E2E_CHROMIUM points at an
|
||||||
|
// installed Chromium binary when `npx playwright install chromium` is not an
|
||||||
|
// option.
|
||||||
|
'use strict';
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
const chromium = process.env.CHESS_E2E_CHROMIUM;
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: '*.spec.js',
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.CHESS_E2E_WEB || 'http://localhost:9090',
|
||||||
|
viewport: { width: 980, height: 480 },
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
acceptDownloads: true,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
launchOptions: chromium ? { executablePath: chromium } : {},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// End-to-end: play a short game on the board, open My games, replay it with
|
||||||
|
// the controls and the keyboard, autoplay to the end, and compare the
|
||||||
|
// exported FEN and PGN with the API. Also the deep link, a checkmate's
|
||||||
|
// replay, and the device list for an anonymous visitor.
|
||||||
|
'use strict';
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const API = process.env.CHESS_E2E_API || 'http://localhost:8080';
|
||||||
|
// Where the server writes password-reset email (-mail-dir); the reset test
|
||||||
|
// is skipped without it.
|
||||||
|
const MAIL_DIR = process.env.CHESS_E2E_MAIL_DIR || '';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||||
|
|
||||||
|
// The engine level and search time are set through the sliders' inputs.
|
||||||
|
async function startGame(page, { color = 'white' } = {}) {
|
||||||
|
await page.click('#new-game-btn');
|
||||||
|
await expect(page.locator('#modal-overlay')).toHaveClass(/show/);
|
||||||
|
await page.locator('#computer-level').fill('0');
|
||||||
|
await page.locator('#search-time').fill('100');
|
||||||
|
await page.check(`input[name="player-color"][value="${color}"]`);
|
||||||
|
await page.click('#start-game-btn');
|
||||||
|
await expect(page.locator('#modal-overlay')).not.toHaveClass(/show/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(page, from, to) {
|
||||||
|
await page.click(`[data-square="${from}"]`);
|
||||||
|
await page.click(`[data-square="${to}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits for the computer's reply: n plies in the list and the board free.
|
||||||
|
async function expectPlies(page, n) {
|
||||||
|
await expect(page.locator('#live-moves .move')).toHaveCount(n, { timeout: 60_000 });
|
||||||
|
await expect(page.locator('#turn-text')).not.toContainText('thinking', { timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(page, request) {
|
||||||
|
const username = `e2e${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
const response = await request.post(`${API}/api/auth/register`, {
|
||||||
|
data: { username, password: 'Passw0rd123' },
|
||||||
|
});
|
||||||
|
expect(response.status(), await response.text()).toBe(201);
|
||||||
|
const auth = await response.json();
|
||||||
|
// The client restores a session from localStorage on load.
|
||||||
|
await page.addInitScript(token => localStorage.setItem('authToken', token), auth.token);
|
||||||
|
return { ...auth, password: 'Passw0rd123' };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('web replay', () => {
|
||||||
|
test('play, list, replay, export', async ({ page, request }) => {
|
||||||
|
const user = await register(page, request);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#auth-label')).toHaveText(user.username);
|
||||||
|
|
||||||
|
await startGame(page);
|
||||||
|
await move(page, 'e2', 'e4');
|
||||||
|
await expectPlies(page, 2);
|
||||||
|
await move(page, 'g1', 'f3');
|
||||||
|
await expectPlies(page, 4);
|
||||||
|
// The live list shows SAN from the server.
|
||||||
|
await expect(page.locator('#live-moves .move').first()).toHaveText('e4');
|
||||||
|
await expect(page.locator('#live-moves .move').nth(2)).toHaveText('Nf3');
|
||||||
|
const gameId = await page.evaluate(() => live.gameId);
|
||||||
|
const liveFen = await page.evaluate(() => live.fen);
|
||||||
|
const history = await (await request.get(`${API}/api/games/${gameId}/history`)).json();
|
||||||
|
expect(history.moves.map(m => m.san)).toEqual(await page.$$eval('#live-moves .move', els => els.map(e => e.textContent)));
|
||||||
|
|
||||||
|
// Copy PGN from the live view: the stored game's export.
|
||||||
|
await page.click('#copy-pgn-btn');
|
||||||
|
await expect(page.locator('#copy-pgn-btn')).toHaveClass(/done/);
|
||||||
|
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
const apiPgn = await (await request.get(`${API}/api/games/${gameId}/pgn`)).text();
|
||||||
|
expect(copied).toBe(apiPgn);
|
||||||
|
|
||||||
|
// My games lists the game with the user's side named.
|
||||||
|
await page.click('#games-btn');
|
||||||
|
await expect(page.locator('#games-overlay')).toHaveClass(/show/);
|
||||||
|
const row = page.locator('#games-list .game-row').first();
|
||||||
|
await expect(row).toContainText('You vs Stockfish L0');
|
||||||
|
await expect(row).toContainText('Ongoing');
|
||||||
|
await expect(row).toHaveClass(/current/);
|
||||||
|
await expect(page).toHaveURL(/\?games$/);
|
||||||
|
await page.click('.seg[data-filter="status"][data-value="finished"]');
|
||||||
|
await expect(page.locator('#games-status')).toContainText('No games match');
|
||||||
|
await page.click('.seg[data-filter="status"][data-value=""]');
|
||||||
|
await expect(page.locator('#games-list .game-row')).toHaveCount(1);
|
||||||
|
|
||||||
|
// Replay from the list opens at the last ply; the live controls hide.
|
||||||
|
await row.click();
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('4 / 4');
|
||||||
|
await expect(page).toHaveURL(new RegExp(`\\?replay=${gameId}&ply=4$`));
|
||||||
|
await expect(page.locator('#pane-live')).toBeHidden();
|
||||||
|
await expect(page.locator('#replay-moves .move[aria-current="true"]')).toHaveText(history.moves[3].san);
|
||||||
|
|
||||||
|
// Backward and forward: buttons, then the keyboard.
|
||||||
|
await page.click('#replay-first');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('0 / 4');
|
||||||
|
await expect(page.locator('#replay-first')).toBeDisabled();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('2 / 4');
|
||||||
|
await expect(page.locator('#ply-announcer')).toContainText('Ply 2 of 4');
|
||||||
|
await expect(page).toHaveURL(new RegExp(`ply=2$`));
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('1 / 4');
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('4 / 4');
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('0 / 4');
|
||||||
|
await page.click('#replay-moves .move[data-ply="3"]');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('3 / 4');
|
||||||
|
|
||||||
|
// The exported FEN and PGN at ply 3 match the API.
|
||||||
|
await page.click('#replay-copy-fen');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(history.moves[2].fenAfterMove);
|
||||||
|
await page.click('#replay-copy-pgn');
|
||||||
|
await expect(page.locator('#replay-copy-pgn')).toHaveClass(/done/);
|
||||||
|
const pgn3 = await (await request.get(`${API}/api/games/${gameId}/pgn?ply=3`)).text();
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(pgn3);
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.click('#replay-download');
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^chess-\d{8}-[0-9a-f]{8}-ply3\.pgn$/);
|
||||||
|
await page.click('#replay-link');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText()))
|
||||||
|
.toBe(`${new URL(page.url()).origin}/?replay=${gameId}&ply=3`);
|
||||||
|
|
||||||
|
// Autoplay to the end at 4x, then pause state clears.
|
||||||
|
await page.selectOption('#replay-speed', '250');
|
||||||
|
await page.click('#replay-play');
|
||||||
|
await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('4 / 4', { timeout: 10_000 });
|
||||||
|
await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
// Flip, then back to the live game, which continues where it was.
|
||||||
|
await page.click('#replay-flip');
|
||||||
|
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
|
||||||
|
await page.click('#replay-exit');
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
|
||||||
|
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('8');
|
||||||
|
expect(await page.evaluate(() => live.fen)).toBe(liveFen);
|
||||||
|
await expect(page.locator('#live-moves .move')).toHaveCount(4);
|
||||||
|
await move(page, 'b1', 'c3');
|
||||||
|
await expectPlies(page, 6);
|
||||||
|
|
||||||
|
// Browser history: back to the replay, back again to the list.
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator('#games-overlay')).toHaveClass(/show/);
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep link to a checkmate replays with the mated king marked', async ({ page, request }) => {
|
||||||
|
// A human-vs-human game over the API: fool's mate.
|
||||||
|
const created = await request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } });
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
const { gameId } = await created.json();
|
||||||
|
for (const m of ['f2f3', 'e7e5', 'g2g4', 'd8h4']) {
|
||||||
|
const r = await request.post(`${API}/api/games/${gameId}/moves`, { data: { move: m } });
|
||||||
|
expect(r.status(), await r.text()).toBe(200);
|
||||||
|
}
|
||||||
|
await page.goto(`/?replay=${gameId}&ply=2`);
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('2 / 4');
|
||||||
|
await expect(page.locator('#replay-result')).toContainText('0-1');
|
||||||
|
await expect(page.locator('#replay-result')).toContainText('Checkmate');
|
||||||
|
await expect(page.locator('#replay-exit-label')).toHaveText('Close replay');
|
||||||
|
await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#');
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/);
|
||||||
|
await expect(page.locator('[data-square="h4"]')).toHaveClass(/last-move-to/);
|
||||||
|
// Escape leaves the replay; with no live game the start position shows.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
|
||||||
|
await expect(page.locator('#turn-text')).toHaveText('No game');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('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 }) => {
|
||||||
|
const context = await browser.newContext({ viewport: { width: 412, height: 780 }, isMobile: true, hasTouch: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
const fits = () => page.evaluate(() => ({
|
||||||
|
w: document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
h: document.documentElement.scrollHeight <= innerHeight,
|
||||||
|
}));
|
||||||
|
expect(await fits()).toEqual({ w: true, h: true });
|
||||||
|
// The controls sit in one row of short labels.
|
||||||
|
await expect(page.locator('#new-game-btn')).toHaveText('New');
|
||||||
|
const [newBox, resignBox] = await Promise.all([
|
||||||
|
page.locator('#new-game-btn').boundingBox(), page.locator('#resign-btn').boundingBox()]);
|
||||||
|
expect(Math.abs(newBox.y - resignBox.y)).toBeLessThan(2);
|
||||||
|
expect(newBox.height).toBeLessThan(50);
|
||||||
|
await expect(page.locator('.pane-live .toolbar')).toBeInViewport();
|
||||||
|
// A replay keeps its controls on screen too.
|
||||||
|
const created = await (await context.request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } })).json();
|
||||||
|
await context.request.post(`${API}/api/games/${created.gameId}/moves`, { data: { move: 'e2e4' } });
|
||||||
|
await page.goto(`/?replay=${created.gameId}&ply=1`);
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
|
||||||
|
expect(await fits()).toEqual({ w: true, h: true });
|
||||||
|
await expect(page.locator('#replay-exit')).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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a forgotten password is reset through the emailed link', async ({ page, request }) => {
|
||||||
|
test.skip(!MAIL_DIR, 'CHESS_E2E_MAIL_DIR is not set (start the server with MAIL_DIR)');
|
||||||
|
const username = `reset${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
const email = `${username}@example.test`;
|
||||||
|
const created = await request.post(`${API}/api/auth/register`, {
|
||||||
|
data: { username, email, password: 'OldPass123' },
|
||||||
|
});
|
||||||
|
expect(created.status(), await created.text()).toBe(201);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#storage-dot')).toHaveAttribute('data-status', 'ok');
|
||||||
|
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.
|
||||||
|
await page.fill('#login-identifier', username);
|
||||||
|
await page.fill('#login-password', 'wrong');
|
||||||
|
await page.click('#login-submit-btn');
|
||||||
|
await expect(page.locator('#auth-modal-message')).toContainText('Wrong username or password');
|
||||||
|
await expect(page.locator('#login-password')).toBeFocused();
|
||||||
|
|
||||||
|
await expect(page.locator('#forgot-btn')).toBeVisible();
|
||||||
|
await page.click('#forgot-btn');
|
||||||
|
await page.fill('#recover-identifier', email);
|
||||||
|
const mails = () => (fs.existsSync(MAIL_DIR) ? fs.readdirSync(MAIL_DIR) : []);
|
||||||
|
const before = mails().length;
|
||||||
|
await page.click('#recover-submit-btn');
|
||||||
|
await expect(page.locator('#auth-modal-message')).toContainText('on its way');
|
||||||
|
await expect.poll(() => mails().length, { timeout: 10_000 }).toBeGreaterThan(before);
|
||||||
|
const files = fs.readdirSync(MAIL_DIR).map(f => path.join(MAIL_DIR, f))
|
||||||
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
const mail = fs.readFileSync(files[0], 'utf8');
|
||||||
|
expect(mail).toContain(`To: ${email}`);
|
||||||
|
const link = /(https?:\/\/\S+\?reset=[A-Za-z0-9_-]+)/.exec(mail)[1];
|
||||||
|
const origin = new URL(page.url()).origin;
|
||||||
|
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
|
||||||
|
await expect(page.locator('#reset-overlay')).toHaveClass(/show/);
|
||||||
|
// The token is removed from the address at once.
|
||||||
|
expect(page.url()).not.toContain('reset=');
|
||||||
|
await page.fill('#reset-password', 'NewPass123');
|
||||||
|
await page.fill('#reset-password-2', 'NewPass124');
|
||||||
|
await page.click('#reset-submit-btn');
|
||||||
|
await expect(page.locator('#reset-modal-message')).toContainText('differ');
|
||||||
|
await page.fill('#reset-password-2', 'NewPass123');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.locator('#auth-label')).toHaveText(username);
|
||||||
|
await expect(page.locator('#reset-overlay')).not.toHaveClass(/show/, { timeout: 5_000 });
|
||||||
|
// The old password is gone, the new one and the link are not reusable.
|
||||||
|
const old = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'OldPass123' } });
|
||||||
|
expect(old.status()).toBe(401);
|
||||||
|
const fresh = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'NewPass123' } });
|
||||||
|
expect(fresh.status()).toBe(200);
|
||||||
|
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
|
||||||
|
await page.fill('#reset-password', 'NewPass125');
|
||||||
|
await page.fill('#reset-password-2', 'NewPass125');
|
||||||
|
await page.click('#reset-submit-btn');
|
||||||
|
await expect(page.locator('#reset-modal-message')).toContainText('invalid or has expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an anonymous visitor replays from the device list', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#auth-label')).toHaveText('Sign in');
|
||||||
|
await startGame(page, { color: 'black' });
|
||||||
|
await expectPlies(page, 1);
|
||||||
|
// Black at the bottom while playing Black.
|
||||||
|
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
|
||||||
|
await page.click('#games-btn');
|
||||||
|
await expect(page.locator('#games-signin-note')).toBeVisible();
|
||||||
|
const row = page.locator('#local-games-list .game-row');
|
||||||
|
await expect(row).toHaveCount(1);
|
||||||
|
await expect(row).toContainText('Stockfish L0 vs You');
|
||||||
|
await row.click();
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
|
||||||
|
await expect(page.locator('#replay-ply')).toHaveText('1 / 1');
|
||||||
|
// The viewer's side stays at the bottom in the replay.
|
||||||
|
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
|
||||||
|
expect(await page.evaluate(() => replay.history.initialFen)).toBe(START_FEN);
|
||||||
|
await page.click('#replay-exit');
|
||||||
|
await expect(page.locator('#turn-text')).toHaveText('Your move');
|
||||||
|
await expect(page.locator('#turn-dot')).toHaveAttribute('data-status', 'black');
|
||||||
|
});
|
||||||
|
});
|
||||||
+41
-2
@@ -6,10 +6,40 @@ 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}
|
||||||
|
# Set WEB_PORT to also serve the embedded web client (for test/e2e), and
|
||||||
|
# MAIL_DIR to enable password recovery with the email written to that
|
||||||
|
# directory instead of sent.
|
||||||
|
WEB_PORT=${WEB_PORT:-}
|
||||||
|
MAIL_DIR=${MAIL_DIR:-}
|
||||||
LOG_LEVEL=${LOG_LEVEL:-debug}
|
LOG_LEVEL=${LOG_LEVEL:-debug}
|
||||||
LOG_HTTP=${LOG_HTTP:-true}
|
LOG_HTTP=${LOG_HTTP:-true}
|
||||||
|
|
||||||
@@ -95,7 +125,8 @@ echo ""
|
|||||||
echo "Configuration:"
|
echo "Configuration:"
|
||||||
echo " Executable: $CHESS_SERVER_EXEC"
|
echo " Executable: $CHESS_SERVER_EXEC"
|
||||||
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
|
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
|
||||||
echo " Port: $API_PORT"
|
echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}"
|
||||||
|
echo " Mail: ${MAIL_DIR:-off (set MAIL_DIR for password recovery)}"
|
||||||
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
|
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
|
||||||
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
|
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
|
||||||
echo " Purpose: Backend for chess-server tests"
|
echo " Purpose: Backend for chess-server tests"
|
||||||
@@ -111,10 +142,18 @@ echo "Starting server..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start chess-server in foreground with dev mode and storage
|
# Start chess-server in foreground with dev mode and storage
|
||||||
|
WEB_FLAGS=()
|
||||||
|
[ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT")
|
||||||
|
if [ -n "$MAIL_DIR" ]; then
|
||||||
|
mkdir -p "$MAIL_DIR"
|
||||||
|
WEB_FLAGS+=(-mail-dir "$MAIL_DIR" -mail-from "Chess <chess@localhost>" \
|
||||||
|
-public-url "http://localhost:${WEB_PORT:-9090}/")
|
||||||
|
fi
|
||||||
"$CHESS_SERVER_EXEC" \
|
"$CHESS_SERVER_EXEC" \
|
||||||
-dev \
|
-dev \
|
||||||
-log-level "$LOG_LEVEL" \
|
-log-level "$LOG_LEVEL" \
|
||||||
-log-http="$LOG_HTTP" \
|
-log-http="$LOG_HTTP" \
|
||||||
-api-port "$API_PORT" \
|
-api-port "$API_PORT" \
|
||||||
|
"${WEB_FLAGS[@]}" \
|
||||||
-pid "$PID_FILE" \
|
-pid "$PID_FILE" \
|
||||||
-pid-lock
|
-pid-lock
|
||||||
@@ -56,6 +56,13 @@
|
|||||||
const fitAddon = new FitAddon.FitAddon();
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
// The client pages long output by the terminal's size, read from here
|
||||||
|
// (display/termsize_wasm.go).
|
||||||
|
globalThis.chessTerminal = {
|
||||||
|
get rows() { return term.rows; },
|
||||||
|
get cols() { return term.cols; }
|
||||||
|
};
|
||||||
|
|
||||||
term.open(screenEl);
|
term.open(screenEl);
|
||||||
|
|
||||||
// WebGL must load after open(); the DOM renderer is the fallback.
|
// WebGL must load after open(); the DOM renderer is the fallback.
|
||||||
|
|||||||
Reference in new issue
Block a user