v0.16.0 import PGN, delete games, two-layout web client
This commit is contained in:
1 parent
e1fa5242e3
commit
664cf9654e
34 files changed
+2007
-150
No files matched your search
@@ -254,6 +254,21 @@ func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error)
|
||||
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) {
|
||||
req := &RegisterRequest{
|
||||
Username: username,
|
||||
|
||||
@@ -131,6 +131,10 @@ type GameHistoryResponse struct {
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
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"`
|
||||
Moves []HistoryMove `json:"moves"`
|
||||
}
|
||||
@@ -154,9 +158,26 @@ type GameSummary struct {
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
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 {
|
||||
Games []GameSummary `json:"games"`
|
||||
Limit int `json:"limit"`
|
||||
|
||||
@@ -98,8 +98,8 @@ func (r *Registry) registerGameCommands() {
|
||||
r.Register(&Command{
|
||||
Name: "games",
|
||||
ShortName: "g",
|
||||
Description: "List your stored games, newest first (login required)",
|
||||
Usage: "games [more]",
|
||||
Description: "List, or delete, your stored games (login required)",
|
||||
Usage: "games [more] | games delete <#|gameId>",
|
||||
Handler: gamesHandler,
|
||||
Paged: true,
|
||||
})
|
||||
@@ -113,6 +113,14 @@ func (r *Registry) registerGameCommands() {
|
||||
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{
|
||||
Name: "poll",
|
||||
ShortName: "p",
|
||||
@@ -686,6 +694,9 @@ func gamesHandler(s *session.Session, args []string) error {
|
||||
if s.AuthToken == "" {
|
||||
return fmt.Errorf("login required")
|
||||
}
|
||||
if len(args) > 0 && args[0] == "delete" {
|
||||
return gamesDeleteHandler(s, args[1:])
|
||||
}
|
||||
cursor := ""
|
||||
if len(args) > 0 && args[0] == "more" {
|
||||
if s.GamesCursor == "" {
|
||||
@@ -718,6 +729,62 @@ func gamesHandler(s *session.Session, args []string) error {
|
||||
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 {
|
||||
switch {
|
||||
case p.Type == 2:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -152,6 +152,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
||||
{"draw", "", ""},
|
||||
{"games", "g", ""},
|
||||
{"pgn", "", ""},
|
||||
{"import", "", ""},
|
||||
}
|
||||
|
||||
replayCommands := []cmdInfo{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -82,9 +82,30 @@ type BoardResponse struct {
|
||||
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
|
||||
// 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
|
||||
// Termination names how a result was reached (see core.Termination; omitted
|
||||
@@ -99,6 +120,10 @@ type GameHistoryResponse struct {
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,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"`
|
||||
Moves []HistoryMove `json:"moves"`
|
||||
}
|
||||
@@ -117,7 +142,8 @@ type HistoryMove struct {
|
||||
// GameSummary is intentionally sufficient for a client-side game picker; the
|
||||
// full move list remains on the per-game history endpoint. FinalFEN is the
|
||||
// position after the last stored move (the initial FEN when there is none);
|
||||
// Termination says how a finished game ended, as in the history.
|
||||
// Termination says how a finished game ended, as in the history; Origin is
|
||||
// "played" or "imported", and Event is the imported game's event tag.
|
||||
type GameSummary struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
@@ -128,6 +154,8 @@ type GameSummary struct {
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
Origin string `json:"origin"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/server/chess"
|
||||
"github.com/lixenwraith/chess/internal/server/core"
|
||||
"github.com/lixenwraith/chess/internal/server/processor"
|
||||
"github.com/lixenwraith/chess/internal/server/service"
|
||||
@@ -229,6 +230,20 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
||||
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
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
|
||||
}
|
||||
@@ -816,6 +831,77 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
||||
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) {
|
||||
raw := c.Query(name)
|
||||
if raw == "" {
|
||||
|
||||
@@ -99,6 +99,8 @@ func TestAPIRoutes(t *testing.T) {
|
||||
"POST /api/games/:gameId/resign",
|
||||
"POST /api/games/:gameId/draw",
|
||||
"GET /api/users/me/games",
|
||||
"DELETE /api/users/me/games/:gameId",
|
||||
"POST /api/games/import",
|
||||
} {
|
||||
if !registered[want] {
|
||||
t.Errorf("route %s is not registered", want)
|
||||
|
||||
@@ -28,6 +28,8 @@ func validationMiddleware(c *fiber.Ctx) error {
|
||||
var requestType any
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/games/import") && method == fiber.MethodPost:
|
||||
requestType = &core.ImportGameRequest{}
|
||||
case strings.HasSuffix(path, "/games") && method == fiber.MethodPost:
|
||||
requestType = &core.CreateGameRequest{}
|
||||
case strings.HasSuffix(path, "/players") && method == fiber.MethodPut:
|
||||
|
||||
@@ -75,9 +75,18 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
|
||||
}
|
||||
}
|
||||
start := record.StartTimeUTC.UTC()
|
||||
event, site, round := "Casual game", "", ""
|
||||
if record.ImportedBy != "" {
|
||||
event, site, round = record.Event, record.Site, record.Round
|
||||
if event == "" {
|
||||
event = "Imported game"
|
||||
}
|
||||
}
|
||||
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: "Round", Value: round},
|
||||
{Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)},
|
||||
{Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)},
|
||||
{Name: "GameId", Value: record.GameID},
|
||||
|
||||
@@ -43,6 +43,10 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
|
||||
Termination: record.Termination,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
Origin: record.Origin(),
|
||||
Event: record.Event,
|
||||
Site: record.Site,
|
||||
Round: record.Round,
|
||||
Players: playersResponse(*record),
|
||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||
}
|
||||
@@ -177,6 +181,8 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
FinalFEN: record.FinalFEN,
|
||||
Origin: record.Origin(),
|
||||
Event: record.Event,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.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.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
|
||||
// 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 {
|
||||
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.end_time_utc IS NULL OR g.end_time_utc < $1)
|
||||
AND NOT EXISTS (
|
||||
@@ -302,7 +303,7 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
}
|
||||
args = append(args, playerID)
|
||||
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"
|
||||
|
||||
@@ -423,7 +424,7 @@ func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummar
|
||||
var where string
|
||||
switch q.Color {
|
||||
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":
|
||||
where = "g.white_claimed_by = $1"
|
||||
case "b":
|
||||
@@ -541,6 +542,7 @@ type rowScanner interface {
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
|
||||
var concessionResult, concessionTermination sql.NullString
|
||||
var importedBy, event, site, round sql.NullString
|
||||
var concessionPly sql.NullInt64
|
||||
var endTime, concessionTime sql.NullTime
|
||||
dest := []any{
|
||||
@@ -551,6 +553,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
&blackClaimed, &blackName,
|
||||
&result, &termination, &record.StartTimeUTC, &endTime,
|
||||
&concessionResult, &concessionTermination, &concessionPly, &concessionTime,
|
||||
&importedBy, &event, &site, &round,
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
@@ -567,6 +570,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
record.ConcessionTermination = concessionTermination.String
|
||||
record.ConcessionPly = int(concessionPly.Int64)
|
||||
record.ConcessionTimeUTC = utcPointer(concessionTime)
|
||||
record.ImportedBy = importedBy.String
|
||||
record.Event = event.String
|
||||
record.Site = site.String
|
||||
record.Round = round.String
|
||||
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) })
|
||||
}
|
||||
|
||||
// OrphanedGames returns games that registered users claimed but whose
|
||||
// claimants were all deleted, idle since cutoff by the anonymous-game rule
|
||||
// (start, last move, and end all before it), excluding live games.
|
||||
// OrphanedGames returns games that registered users claimed or imported but
|
||||
// whose claimants and importer were all deleted, idle since cutoff by the
|
||||
// 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) {
|
||||
if live == nil {
|
||||
live = []string{}
|
||||
@@ -72,10 +73,10 @@ func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error)
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
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 (
|
||||
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.end_time_utc IS NULL OR g.end_time_utc < $1)
|
||||
|
||||
@@ -47,6 +47,20 @@ type GameRecord struct {
|
||||
ConcessionTermination string
|
||||
ConcessionPly int
|
||||
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
|
||||
@@ -81,7 +95,7 @@ type MovePersistence struct {
|
||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||
// a failed upgrade leaves the previous version intact.
|
||||
const schemaVersion = 4
|
||||
const schemaVersion = 5
|
||||
|
||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||
// released migration; append a new one.
|
||||
@@ -227,6 +241,23 @@ CREATE TABLE password_resets (
|
||||
CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32)
|
||||
);
|
||||
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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
|
||||
{
|
||||
name: "anonymous purge candidates",
|
||||
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`,
|
||||
args: []any{time.Now()},
|
||||
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_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_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 {
|
||||
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END,
|
||||
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
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
|
||||
|
||||
@@ -124,7 +124,7 @@ function setModalMessage(elementId, message, type = 'error') {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.remove('error', 'success');
|
||||
el.classList.remove('error', 'success', 'hint');
|
||||
el.classList.add('show', type);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ function clearModalMessage(elementId) {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = '';
|
||||
el.classList.remove('show', 'error', 'success');
|
||||
el.classList.remove('show', 'error', 'success', 'hint');
|
||||
}
|
||||
|
||||
function anyModalOpen() {
|
||||
@@ -377,7 +377,15 @@ function setStorageStatus(status) {
|
||||
|
||||
function setMailStatus(status) {
|
||||
ui.mail = status;
|
||||
$('forgot-row').classList.toggle('hidden', status !== 'ok');
|
||||
}
|
||||
|
||||
// A neutral hint fills a modal's message slot until a result replaces it.
|
||||
function setModalHint(elementId, text) {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove('error', 'success');
|
||||
el.classList.add('show', 'hint');
|
||||
}
|
||||
|
||||
function startHealthCheck() {
|
||||
@@ -467,7 +475,7 @@ function showAccountModal() {
|
||||
$('account-username').textContent = auth.username || '';
|
||||
$('delete-account').open = false;
|
||||
$('delete-password').value = '';
|
||||
clearModalMessage('account-modal-message');
|
||||
setModalHint('account-modal-message', 'Signing out keeps your games; deleting the account removes your name from them.');
|
||||
$('account-overlay').classList.add('show');
|
||||
document.removeEventListener('keydown', handleAccountKeydown);
|
||||
document.addEventListener('keydown', handleAccountKeydown);
|
||||
@@ -624,7 +632,11 @@ function switchAuthTab(tab) {
|
||||
$('login-form').classList.toggle('hidden', tab !== 'login');
|
||||
$('register-form').classList.toggle('hidden', tab !== 'register');
|
||||
$('recover-form').classList.toggle('hidden', tab !== 'recover');
|
||||
clearModalMessage('auth-modal-message');
|
||||
setModalHint('auth-modal-message', {
|
||||
login: 'Your games are kept under your account.',
|
||||
register: 'A username and a password; the email is optional and lets you reset a forgotten password.',
|
||||
recover: ui.mail === 'ok' ? 'The link in the email works for an hour.' : 'This server may not send email yet; the administrator can give you a reset link.',
|
||||
}[tab]);
|
||||
$({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus();
|
||||
}
|
||||
|
||||
@@ -1070,6 +1082,7 @@ function setNewGameModalBusy(busy) {
|
||||
|
||||
function showNewGameModal() {
|
||||
if (replay.active) return;
|
||||
setModalHint('new-game-modal-message', 'Level 0 is a beginner, 20 is full strength; the search time is how long it thinks per move.');
|
||||
$('modal-overlay').classList.add('show');
|
||||
document.addEventListener('keydown', handleModalKeydown);
|
||||
$('start-game-btn').focus();
|
||||
@@ -1744,6 +1757,7 @@ function renderGamesSections() {
|
||||
$('games-filters').classList.toggle('hidden', !signedIn);
|
||||
$('account-games-heading').textContent = 'Your games';
|
||||
$('games-list').classList.toggle('hidden', !signedIn);
|
||||
$('games-import-btn').classList.toggle('hidden', !signedIn);
|
||||
$('games-more').classList.toggle('hidden', !signedIn || !games.cursor);
|
||||
if (!signedIn) $('games-status').textContent = '';
|
||||
renderLocalGames();
|
||||
@@ -1855,6 +1869,7 @@ function renderLocalGames() {
|
||||
// affordance. The whole row is a button.
|
||||
function renderGameRow(summary) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'game-item';
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'game-row';
|
||||
@@ -1886,7 +1901,16 @@ function renderGameRow(summary) {
|
||||
: `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`));
|
||||
const when = document.createElement('div');
|
||||
when.className = 'game-when';
|
||||
when.textContent = formatDate(summary.startTimeUtc) + (summary.gameId === live.gameId ? ' · this game' : '');
|
||||
const notes = [formatDate(summary.startTimeUtc)];
|
||||
if (summary.origin === 'imported') {
|
||||
const origin = document.createElement('span');
|
||||
origin.className = 'game-origin';
|
||||
origin.textContent = summary.event ? `imported · ${summary.event}` : 'imported';
|
||||
when.append(document.createTextNode(notes[0] + ' · '), origin);
|
||||
} else {
|
||||
if (summary.gameId === live.gameId) notes.push('this game');
|
||||
when.textContent = notes.join(' · ');
|
||||
}
|
||||
info.append(players, outcome, when);
|
||||
|
||||
const go = document.createElement('span');
|
||||
@@ -1898,9 +1922,203 @@ function renderGameRow(summary) {
|
||||
row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`);
|
||||
row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0));
|
||||
li.appendChild(row);
|
||||
|
||||
// Delete, with an in-row confirmation.
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn game-delete';
|
||||
del.title = summary.local ? 'Forget this game on this device' : 'Delete this game';
|
||||
del.setAttribute('aria-label', `${del.title}: ${players.textContent}`);
|
||||
del.innerHTML = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
del.addEventListener('click', () => confirmDeleteRow(li, del, summary));
|
||||
li.appendChild(del);
|
||||
return li;
|
||||
}
|
||||
|
||||
// Replaces the row's delete button with Yes/No for one decision.
|
||||
function confirmDeleteRow(li, del, summary) {
|
||||
const confirm = document.createElement('div');
|
||||
confirm.className = 'game-confirm';
|
||||
confirm.setAttribute('role', 'group');
|
||||
confirm.setAttribute('aria-label', 'Confirm deletion');
|
||||
const yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'btn btn-danger';
|
||||
yes.textContent = summary.local ? 'Forget' : 'Delete';
|
||||
const no = document.createElement('button');
|
||||
no.type = 'button';
|
||||
no.className = 'btn btn-secondary';
|
||||
no.textContent = 'Keep';
|
||||
const restore = () => { confirm.replaceWith(del); del.focus(); };
|
||||
no.addEventListener('click', restore);
|
||||
yes.addEventListener('click', async () => {
|
||||
yes.disabled = true;
|
||||
no.disabled = true;
|
||||
const ok = await deleteGame(summary);
|
||||
if (!ok) restore();
|
||||
});
|
||||
confirm.append(yes, no);
|
||||
del.replaceWith(confirm);
|
||||
yes.focus();
|
||||
}
|
||||
|
||||
// Deletes a game from the account list, or forgets a device entry. Returns
|
||||
// whether the row is gone.
|
||||
async function deleteGame(summary) {
|
||||
if (summary.local) {
|
||||
saveRecentGames(loadRecentGames().filter(g => g.gameId !== summary.gameId));
|
||||
renderLocalGames();
|
||||
toast('Game forgotten on this device', 'success');
|
||||
return true;
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await apiJSON('DELETE', `/api/users/me/games/${summary.gameId}`);
|
||||
} catch (error) {
|
||||
handleApiError('delete game', error);
|
||||
return false;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
handleApiError('delete game', null, response);
|
||||
return false;
|
||||
}
|
||||
let outcome = 'game';
|
||||
try {
|
||||
outcome = (await response.json()).removed || 'game';
|
||||
} catch { /* the row is gone either way */ }
|
||||
games.items = games.items.filter(g => g.gameId !== summary.gameId);
|
||||
renderGamesList();
|
||||
toast(outcome === 'claim' ? 'Removed from your games; the other player keeps it' : 'Game deleted', 'success', 2500);
|
||||
if (replay.gameId === summary.gameId && outcome === 'game') exitReplay({ push: false });
|
||||
if (live.gameId === summary.gameId && outcome === 'game') dropLiveGame();
|
||||
ui.historyCache.delete(summary.gameId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The live game was deleted from storage: the board returns to no game.
|
||||
function dropLiveGame() {
|
||||
stopPolling();
|
||||
live.gameId = null;
|
||||
live.fen = null;
|
||||
live.state = null;
|
||||
live.moves = [];
|
||||
live.san = [];
|
||||
live.lastMove = null;
|
||||
live.isLocked = false;
|
||||
live.selectedSquare = null;
|
||||
if (!replay.active) renderPosition(START_FEN);
|
||||
renderLiveMoves();
|
||||
updateTurnStrip();
|
||||
updateGameControls();
|
||||
}
|
||||
|
||||
// --- import: a PGN pasted or picked from a file, stored under the account
|
||||
|
||||
const importState = { busy: false };
|
||||
|
||||
function openImportModal() {
|
||||
if (!auth.token) {
|
||||
showAuthModal();
|
||||
return;
|
||||
}
|
||||
importState.busy = false;
|
||||
$('import-pgn').value = '';
|
||||
$('import-file').value = '';
|
||||
setModalHint('import-modal-message', 'One game: tags in brackets, then the moves. Comments and variations are dropped; every move is checked.');
|
||||
$('import-overlay').classList.add('show');
|
||||
document.removeEventListener('keydown', handleImportKeydown);
|
||||
document.addEventListener('keydown', handleImportKeydown);
|
||||
$('import-pgn').focus();
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
if (importState.busy) return;
|
||||
$('import-overlay').classList.remove('show');
|
||||
document.removeEventListener('keydown', handleImportKeydown);
|
||||
}
|
||||
|
||||
function handleImportKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeImportModal();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitImport();
|
||||
}
|
||||
}
|
||||
|
||||
function setImportBusy(busy) {
|
||||
importState.busy = busy;
|
||||
for (const id of ['import-pgn', 'import-file', 'import-submit-btn', 'import-cancel-btn']) $(id).disabled = busy;
|
||||
}
|
||||
|
||||
// A picked file fills the text box; the size limit is the server's.
|
||||
function readImportFile() {
|
||||
const file = $('import-file').files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 65536) {
|
||||
setModalMessage('import-modal-message', 'That file is larger than 64 KiB; one game is a few kilobytes', 'error');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
$('import-pgn').value = String(reader.result || '');
|
||||
setModalHint('import-modal-message', `${file.name} loaded; press Import.`);
|
||||
};
|
||||
reader.onerror = () => setModalMessage('import-modal-message', 'Could not read the file', 'error');
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (importState.busy) return;
|
||||
const pgn = $('import-pgn').value.trim();
|
||||
if (!pgn) {
|
||||
setModalMessage('import-modal-message', 'Paste a PGN or pick a file', 'error');
|
||||
$('import-pgn').focus();
|
||||
return;
|
||||
}
|
||||
setImportBusy(true);
|
||||
clearModalMessage('import-modal-message');
|
||||
let response;
|
||||
try {
|
||||
response = await apiJSON('POST', '/api/games/import', { pgn });
|
||||
} catch (error) {
|
||||
setImportBusy(false);
|
||||
setModalMessage('import-modal-message', handleApiError('import', error).statusMessage, 'error');
|
||||
return;
|
||||
}
|
||||
setImportBusy(false);
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
setModalMessage('import-modal-message', 'Sign in again to import', 'error');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const err = await parseErrorResponse(response);
|
||||
setModalMessage('import-modal-message', response.status === 400
|
||||
? `Not a valid game: ${err.details || err.error}`
|
||||
: response.status === 429 ? 'Too many imports; wait a minute' : (err.error || `Server error (${response.status})`), 'error');
|
||||
$('import-pgn').focus();
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch {
|
||||
setModalMessage('import-modal-message', 'Unexpected response from server', 'error');
|
||||
return;
|
||||
}
|
||||
closeImportModal();
|
||||
toast(`Imported, ${Math.ceil((result.moveCount || 0) / 2)} moves`, 'success', 2500);
|
||||
games.cursor = '';
|
||||
await loadGames(true, true);
|
||||
enterReplay(result.gameId, 0);
|
||||
}
|
||||
|
||||
// A side's name in the list, "You" for the signed-in user's claim.
|
||||
function sideLabel(player) {
|
||||
if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You';
|
||||
@@ -2039,6 +2257,7 @@ function renderReplayHead() {
|
||||
$('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`;
|
||||
const plies = h.moves.length;
|
||||
const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`];
|
||||
if (h.origin === 'imported') meta.unshift(h.event || 'imported');
|
||||
if (h.concession && h.concession.termination) {
|
||||
meta.push(h.concession.termination === 'agreement'
|
||||
? `draw agreed at ply ${h.concession.ply}`
|
||||
@@ -2352,6 +2571,19 @@ function bindEvents() {
|
||||
$('promotion-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closePromotion(null);
|
||||
});
|
||||
// A click on a backdrop closes the dialog, unless a request is in flight.
|
||||
const backdrop = (id, close) => $(id).addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
backdrop('auth-modal-overlay', () => { if (!auth.busy) hideAuthModal(); });
|
||||
backdrop('account-overlay', hideAccountModal);
|
||||
backdrop('modal-overlay', () => { if (!live.newGameBusy) hideNewGameModal(); });
|
||||
backdrop('resign-overlay', hideResignModal);
|
||||
backdrop('reset-overlay', closeResetModal);
|
||||
backdrop('import-overlay', closeImportModal);
|
||||
|
||||
$('games-import-btn').addEventListener('click', openImportModal);
|
||||
$('import-submit-btn').addEventListener('click', submitImport);
|
||||
$('import-cancel-btn').addEventListener('click', closeImportModal);
|
||||
$('import-file').addEventListener('change', readImportFile);
|
||||
|
||||
$('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); });
|
||||
$('games-close').addEventListener('click', () => closeGames());
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
<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>
|
||||
@@ -225,7 +229,7 @@
|
||||
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
|
||||
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||
</div>
|
||||
<p class="auth-aside hidden" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
|
||||
<p class="auth-aside" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
|
||||
</div>
|
||||
|
||||
<div id="recover-form" class="auth-form hidden">
|
||||
@@ -261,6 +265,31 @@
|
||||
</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">
|
||||
|
||||
@@ -50,9 +50,13 @@
|
||||
(the host's 30rem frame leaves 416px), so the embed keeps a 360px
|
||||
board while a full window gets up to 560px. */
|
||||
--topbar-height: 2.5rem;
|
||||
--board-edge: clamp(360px, min(32vw, calc(100vh - 64px)), 560px);
|
||||
--board-pad: clamp(16px, 1.04vw, 20px);
|
||||
--panel-width: clamp(280px, 20vw, 380px);
|
||||
--panel-width: clamp(260px, 22vw, 380px);
|
||||
/* The board takes what the window leaves beside the panel and under the
|
||||
top bar, so the panel is never pushed out of view and the row never
|
||||
scrolls: 5rem covers the gaps and the frame's padding. The host's
|
||||
30rem desktop frame leaves 416px. */
|
||||
--board-edge: clamp(280px, min(100vw - var(--panel-width) - 5rem, 100vh - var(--topbar-height) - 2.5rem), 600px);
|
||||
--board-pad: clamp(14px, 1.04vw, 20px);
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -404,8 +408,18 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; }
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* A narrow panel shows the export buttons as icons. */
|
||||
@container (max-width: 300px) {
|
||||
.toolbar .btn-tool span { display: none; }
|
||||
.toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
|
||||
.controls .btn { font-size: 0.78rem; padding: 0.5rem 0.2rem; }
|
||||
}
|
||||
|
||||
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
|
||||
|
||||
.pane {
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
@@ -566,7 +580,7 @@ body[data-mode="replay"] .pane-live { display: none; }
|
||||
}
|
||||
|
||||
.replay-result { color: var(--fg); font-weight: 600; }
|
||||
.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; }
|
||||
.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; }
|
||||
|
||||
.replay-players {
|
||||
color: var(--fg);
|
||||
@@ -822,6 +836,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
}
|
||||
|
||||
.modal-message.show { visibility: visible; }
|
||||
.modal-message.hint { color: var(--muted); }
|
||||
.modal-message.error { background: rgba(247, 118, 142, 0.12); color: var(--red); border-color: rgba(247, 118, 142, 0.4); }
|
||||
.modal-message.success { background: rgba(158, 206, 106, 0.12); color: var(--green); border-color: rgba(158, 206, 106, 0.4); }
|
||||
|
||||
@@ -843,6 +858,11 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
|
||||
.confirm-modal { max-width: 320px; }
|
||||
|
||||
.import-modal { max-width: 460px; }
|
||||
.pgn-input { min-height: 9rem; }
|
||||
.file-label { margin: 0; }
|
||||
#import-file { max-width: 55%; font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
.promotion-modal { max-width: 320px; }
|
||||
|
||||
.promotion-choices {
|
||||
@@ -1020,6 +1040,38 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* A row's delete control sits beside the row; a confirmation replaces it. */
|
||||
.game-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.game-item .game-row { flex: 1; min-width: 0; }
|
||||
|
||||
.game-delete {
|
||||
flex: none;
|
||||
width: 2.2rem;
|
||||
padding: 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.game-delete:hover:not(:disabled) { color: var(--red); border-color: var(--red); transform: none; }
|
||||
|
||||
.game-confirm {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.game-confirm .btn { padding: 0.3rem 0.5rem; font-size: 0.78rem; }
|
||||
.game-origin { color: var(--periwinkle); }
|
||||
|
||||
.games-status {
|
||||
margin: 0.4rem 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -1033,104 +1085,18 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
#games-more { margin-top: 0.4rem; }
|
||||
#games-more.hidden { display: none; }
|
||||
|
||||
/* --- Stacked layout (board over panel) -------------------------------- */
|
||||
|
||||
@media (min-width: 641px) and (max-width: 978px) {
|
||||
:root {
|
||||
--panel-width: clamp(360px, 45vw, 440px);
|
||||
--panel-height: 230px;
|
||||
/* Board edge = the smaller of the width-derived size and the height
|
||||
that is left: topbar 40 + gaps 16 + panel 230 + app padding 16 +
|
||||
slack 40 = 342. A short frame shrinks the board before anything
|
||||
scrolls. */
|
||||
--board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 342px)));
|
||||
--board-pad: clamp(16px, 2.04vw, 20px);
|
||||
}
|
||||
|
||||
body { overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
|
||||
.app { height: auto; min-height: 100%; }
|
||||
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: var(--panel-width);
|
||||
height: var(--panel-height);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(150px, 18.4vw, 180px) minmax(0, 1fr);
|
||||
grid-template-rows: min-content 1fr;
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.pane > * { min-width: 0; }
|
||||
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
|
||||
/* Four export buttons in the narrow right column: icons only. */
|
||||
.pane-replay .toolbar .btn-tool span { display: none; }
|
||||
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
|
||||
|
||||
body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; }
|
||||
|
||||
.pane-live .turn-strip { grid-column: 1; grid-row: 1; }
|
||||
.pane-live .controls {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-self: stretch;
|
||||
}
|
||||
.pane-live .controls .btn { padding: 0.3rem 0.2rem; font-size: 0.78rem; min-height: 2rem; }
|
||||
.pane-live .moves { grid-column: 2; grid-row: 1 / 3; }
|
||||
.pane-live .toolbar { grid-column: 2; grid-row: 3; }
|
||||
.pane-live { grid-template-rows: min-content 1fr min-content; }
|
||||
.pane-live .controls { grid-row: 2 / 4; }
|
||||
|
||||
/* Replay: head, navigation and the exit button down the left; the move
|
||||
list with the export row on the right. */
|
||||
.pane-replay { grid-template-rows: min-content min-content 1fr min-content; }
|
||||
.pane-replay .replay-head { grid-column: 1; grid-row: 1; padding: 0.3rem 0.5rem; }
|
||||
.pane-replay .replay-nav {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.pane-replay .replay-nav .speed { grid-column: 1 / 5; margin: 0; }
|
||||
.pane-replay .replay-nav .speed select { width: 100%; }
|
||||
.pane-replay #replay-exit { grid-column: 1; grid-row: 3 / 5; align-self: end; padding: 0.35rem; font-size: 0.78rem; }
|
||||
.pane-replay .moves { grid-column: 2; grid-row: 1 / 4; }
|
||||
.pane-replay .toolbar { grid-column: 2; grid-row: 4; }
|
||||
.pane-replay .btn-nav { width: 100%; height: 1.65rem; }
|
||||
.pane-replay .btn-nav .icon { width: 1rem; height: 1rem; }
|
||||
.pane-replay .speed select { height: 1.65rem; font-size: 0.72rem; padding: 0 0.2rem; }
|
||||
.pane-replay .replay-meta { display: none; }
|
||||
.pane-replay .replay-title-row { flex-wrap: wrap; gap: 0.2rem 0.4rem; }
|
||||
.pane-replay .replay-players { font-size: 0.76rem; }
|
||||
.pane-replay .replay-result { font-size: 0.76rem; }
|
||||
|
||||
.move-grid { grid-template-columns: 2rem 1fr 1fr; font-size: 0.84rem; }
|
||||
.btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; }
|
||||
}
|
||||
|
||||
/* --- Phone layout: full-width rows, edge to edge ------------------------ */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 699px) {
|
||||
:root {
|
||||
/* Reserve under the board: top bar 40 + gap 6 + turn strip 32 +
|
||||
controls 36 + move list 96 + toolbar 36 + gaps and padding 26 =
|
||||
276. Small viewport units, so the browser's bars never cover the
|
||||
panel. The replay pane is taller (head, navigation, export, exit),
|
||||
so a replay reserves more and the board gives way on a short
|
||||
screen rather than the controls dropping below the fold. */
|
||||
--board-edge: max(240px, min(100vw, calc(100svh - 284px)));
|
||||
/* Reserve under the board, in rem so a larger system font grows it:
|
||||
top bar 2.5 + gap 0.4 + turn strip 2 + controls 2.25 + move list 6
|
||||
+ toolbar 2.25 + gaps and padding 2.35 = 17.75rem. Small viewport
|
||||
units, so the browser's bars never cover the panel. The replay
|
||||
pane is taller (head, navigation, export, exit), so a replay
|
||||
reserves more and the board gives way on a short screen rather
|
||||
than the controls dropping below the fold. */
|
||||
--board-edge: max(240px, min(100vw, calc(100svh - 17.75rem)));
|
||||
--board-pad: 14px;
|
||||
--panel-width: 100%;
|
||||
--panel-height: auto;
|
||||
@@ -1138,7 +1104,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
}
|
||||
|
||||
body { overflow-y: auto; overflow-x: hidden; }
|
||||
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 372px))); }
|
||||
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 23.25rem))); }
|
||||
|
||||
/* The app is exactly the viewport, so the panel and its move list shrink
|
||||
to fit; only when their minimums do not fit does the page scroll. */
|
||||
@@ -1174,6 +1140,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
.coordinates.ranks { left: -14px; }
|
||||
.coordinates.files { top: -15px; }
|
||||
|
||||
/* The panel never clips: what the viewport cannot hold scrolls the page. */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1185,8 +1152,13 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
overflow: visible;
|
||||
container-type: normal;
|
||||
}
|
||||
|
||||
/* min-height 0: a flex item's automatic minimum is its content height,
|
||||
which would let a long move list push the controls below the fold
|
||||
instead of scrolling inside its box. */
|
||||
.pane, .pane-live, .pane-replay {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -1194,6 +1166,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem 0.6rem;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane > * { min-width: 0; }
|
||||
@@ -1207,7 +1180,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
}
|
||||
.pane-live .controls .btn { padding: 0.5rem 0.2rem; font-size: 0.85rem; min-height: 2.25rem; height: auto; }
|
||||
|
||||
.moves { flex: 1 1 auto; min-height: 96px; }
|
||||
.moves { flex: 1 1 auto; min-height: 6rem; }
|
||||
.move-grid { grid-template-columns: 2.2rem 1fr 1fr; font-size: 0.9rem; }
|
||||
|
||||
.toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; }
|
||||
|
||||
Reference in new issue
Block a user