v0.16.0 import PGN, delete games, two-layout web client
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 21:02:39 -04:00
1 parent e1fa5242e3
commit 664cf9654e
34 files changed
+2007 -150

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
package chess
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// Limits on imported PGN. One game of ordinary length is a few kilobytes;
// the longest games on record are under 300 moves.
const (
MaxPGNSize = 64 << 10
MaxPGNPlies = 1000
maxPGNTags = 64
maxPGNTagName = 32
maxPGNTagValue = 255
maxPGNTokenLength = 16
maxVariationDepth = 16
)
// ErrInvalidPGN reports text that is not one well-formed PGN game.
var ErrInvalidPGN = errors.New("invalid PGN")
// ParsedPGN is one game read from PGN import format: its tag pairs in order
// and the main line's SAN moves. Comments, annotations, numeric annotation
// glyphs and variations are read and dropped.
type ParsedPGN struct {
Tags []Tag
SAN []string
Result string // "1-0", "0-1", "1/2-1/2", or "*"
}
// Tag returns the value of the named tag, or "" when absent.
func (g *ParsedPGN) Tag(name string) string {
for _, t := range g.Tags {
if t.Name == name {
return t.Value
}
}
return ""
}
var (
pgnTagName = regexp.MustCompile(`^[A-Za-z0-9_]{1,32}$`)
// A SAN token: castling, or piece, disambiguation, capture, destination,
// promotion, with check and annotation suffixes.
pgnSAN = regexp.MustCompile(`^(?:O-O(?:-O)?|0-0(?:-0)?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=?[QRBN])?)[+#]?[!?]{0,2}$`)
)
func pgnError(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidPGN, fmt.Sprintf(format, args...))
}
// ParsePGN reads exactly one game. It is strict about form: well-formed tag
// pairs, printable text only, balanced comments and variations, and SAN
// tokens of the standard shape; anything else is refused rather than
// guessed at. Move legality is not checked here (see Replay and ParseSAN).
func ParsePGN(text string) (*ParsedPGN, error) {
if len(text) > MaxPGNSize {
return nil, pgnError("larger than %d bytes", MaxPGNSize)
}
text = strings.TrimPrefix(text, "\uFEFF")
if !utf8.ValidString(text) {
return nil, pgnError("not valid UTF-8")
}
for i, r := range text {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if unicode.IsControl(r) || !unicode.IsPrint(r) && !unicode.IsSpace(r) {
return nil, pgnError("control character at byte %d", i)
}
}
text = strings.ReplaceAll(text, "\r\n", "\n")
game := &ParsedPGN{}
rest, err := parsePGNTags(text, game)
if err != nil {
return nil, err
}
if err := parsePGNMovetext(rest, game); err != nil {
return nil, err
}
if len(game.SAN) == 0 {
return nil, pgnError("no moves")
}
if tag := game.Tag("Result"); tag != "" {
switch tag {
case "1-0", "0-1", "1/2-1/2", "*":
default:
return nil, pgnError("Result tag %q is not a result", tag)
}
if game.Result == "" {
game.Result = tag
} else if game.Result != tag {
return nil, pgnError("Result tag %q disagrees with the movetext result %q", tag, game.Result)
}
}
if game.Result == "" {
game.Result = "*"
}
return game, nil
}
// parsePGNTags reads the tag section and returns the movetext that follows.
func parsePGNTags(text string, game *ParsedPGN) (string, error) {
seen := make(map[string]bool)
for {
trimmed := strings.TrimLeft(text, " \t\n")
if !strings.HasPrefix(trimmed, "[") {
return text, nil
}
end := strings.IndexByte(trimmed, '\n')
if end < 0 {
end = len(trimmed)
}
line := trimmed[:end]
tag, err := parsePGNTag(line)
if err != nil {
return "", err
}
if seen[tag.Name] {
return "", pgnError("tag %s given twice", tag.Name)
}
if len(game.Tags) >= maxPGNTags {
return "", pgnError("more than %d tags", maxPGNTags)
}
seen[tag.Name] = true
game.Tags = append(game.Tags, tag)
text = trimmed[end:]
}
}
// parsePGNTag reads one `[Name "Value"]` line.
func parsePGNTag(line string) (Tag, error) {
body := strings.TrimSpace(line)
if !strings.HasPrefix(body, "[") || !strings.HasSuffix(body, "]") {
return Tag{}, pgnError("malformed tag line %q", line)
}
body = strings.TrimSpace(body[1 : len(body)-1])
space := strings.IndexAny(body, " \t")
if space < 0 {
return Tag{}, pgnError("malformed tag line %q", line)
}
name := body[:space]
if !pgnTagName.MatchString(name) {
return Tag{}, pgnError("invalid tag name %q", name)
}
quoted := strings.TrimSpace(body[space:])
if len(quoted) < 2 || quoted[0] != '"' || quoted[len(quoted)-1] != '"' {
return Tag{}, pgnError("tag %s value is not quoted", name)
}
var value strings.Builder
escaped := false
inner := quoted[1 : len(quoted)-1]
for i := 0; i < len(inner); i++ {
c := inner[i]
switch {
case escaped:
if c != '\\' && c != '"' {
return Tag{}, pgnError("tag %s has an invalid escape", name)
}
value.WriteByte(c)
escaped = false
case c == '\\':
escaped = true
case c == '"':
return Tag{}, pgnError("tag %s value has an unescaped quote", name)
default:
value.WriteByte(c)
}
}
if escaped {
return Tag{}, pgnError("tag %s value ends in a backslash", name)
}
if utf8.RuneCountInString(value.String()) > maxPGNTagValue {
return Tag{}, pgnError("tag %s value longer than %d characters", name, maxPGNTagValue)
}
return Tag{Name: name, Value: value.String()}, nil
}
// parsePGNMovetext tokenizes the movetext: moves of the main line are kept,
// comments, variations, annotations and move numbers are read past, and
// the game ends at a result token.
func parsePGNMovetext(text string, game *ParsedPGN) error {
depth := 0
lineStart := true
i := 0
for i < len(text) {
c := text[i]
switch {
case c == '\n':
lineStart = true
i++
continue
case c == ' ' || c == '\t':
i++
continue
case c == '%' && lineStart:
// Escape mechanism: the rest of the line is for other software.
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return nil
}
i += end
continue
}
lineStart = false
if game.Result != "" {
return pgnError("text after the result (one game per import)")
}
switch c {
case '{':
end := strings.IndexByte(text[i:], '}')
if end < 0 {
return pgnError("unterminated comment")
}
i += end + 1
case '}':
return pgnError("unexpected }")
case ';':
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return nil
}
i += end
case '(':
depth++
if depth > maxVariationDepth {
return pgnError("variations nested deeper than %d", maxVariationDepth)
}
i++
case ')':
depth--
if depth < 0 {
return pgnError("unexpected )")
}
i++
case '$':
j := i + 1
for j < len(text) && j-i <= 3 && text[j] >= '0' && text[j] <= '9' {
j++
}
if j == i+1 {
return pgnError("malformed numeric annotation glyph")
}
i = j
case '*':
if depth == 0 {
game.Result = "*"
}
i++
default:
// A token of visible, non-delimiter characters.
j := i
for j < len(text) && !strings.ContainsRune(" \t\n{}();$", rune(text[j])) {
j++
}
token := text[i:j]
if len(token) > maxPGNTokenLength {
return pgnError("token %q too long", token[:maxPGNTokenLength]+"…")
}
i = j
if err := pgnToken(token, depth, game); err != nil {
return err
}
}
}
if depth != 0 {
return pgnError("unterminated variation")
}
return nil
}
// pgnToken classifies one movetext token: a result, a move number, or a SAN
// move (kept when on the main line).
func pgnToken(token string, depth int, game *ParsedPGN) error {
switch token {
case "1-0", "0-1", "1/2-1/2":
if depth == 0 {
game.Result = token
}
return nil
}
if token[0] >= '0' && token[0] <= '9' && !strings.HasPrefix(token, "0-0") {
// A move number: digits and dots ("12." or "12..."), possibly glued
// to the move that follows ("12.Nf3").
digits := strings.TrimLeft(token, "0123456789")
number := token[:len(token)-len(digits)]
if len(number) > 4 || !strings.HasPrefix(digits, ".") {
return pgnError("unexpected token %q", token)
}
if rest := strings.TrimLeft(digits, "."); rest != "" {
return pgnToken(rest, depth, game)
}
return nil
}
if !pgnSAN.MatchString(token) {
return pgnError("unexpected token %q", token)
}
if depth > 0 {
return nil
}
if len(game.SAN) >= MaxPGNPlies {
return pgnError("more than %d plies", MaxPGNPlies)
}
game.SAN = append(game.SAN, strings.TrimRight(token, "!?"))
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package chess
import (
"errors"
"strings"
"testing"
)
const scholarsMate = `[Event "Casual game"]
[Site "?"]
[Date "2026.01.02"]
[Round "-"]
[White "alice"]
[Black "bob"]
[Result "1-0"]
[Note "with \"quotes\" and a \\ backslash"]
1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? {a blunder} ( 3... g6 $1 4. Qf3 ) 4. Qxf7# 1-0
`
func TestParsePGN(t *testing.T) {
game, err := ParsePGN(scholarsMate)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(game.SAN, " "); got != "e4 e5 Bc4 Nc6 Qh5 Nf6 Qxf7#" {
t.Errorf("SAN = %q", got)
}
if game.Result != "1-0" || game.Tag("White") != "alice" || game.Tag("Round") != "-" {
t.Errorf("result %q, tags %v", game.Result, game.Tags)
}
if game.Tag("Note") != `with "quotes" and a \ backslash` {
t.Errorf("escaped value = %q", game.Tag("Note"))
}
if len(game.Tags) != 8 {
t.Errorf("%d tags", len(game.Tags))
}
// Legal in the rules core, and the final position is mate.
pos, _ := ParseFEN(StartFEN)
for i, san := range game.SAN {
m, err := pos.ParseSAN(san)
if err != nil {
t.Fatalf("ply %d %q: %v", i+1, san, err)
}
pos = pos.Play(m)
}
if !pos.InCheck() || pos.HasLegalMoves() {
t.Error("final position is not mate")
}
}
func TestParsePGNAcceptsLooseButValidForms(t *testing.T) {
for name, text := range map[string]string{
"no tags, no result": "1.e4 e5 2.Nf3",
"semicolon comment": "1. e4 ; best by test\ne5 *",
"black first": "[FEN \"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1\"]\n[SetUp \"1\"]\n\n1... e5 2. Nf3",
"castling zeros": "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 Nf6 5. 0-0-0",
"escape line": "%ignored line\n1. e4 e5 1/2-1/2",
"nag and annotations": "1. e4!! $14 e5?! 2. Nf3+ ",
"windows line endings": "[Event \"x\"]\r\n\r\n1. e4 e5 0-1\r\n",
"byte order mark": "\uFEFF1. d4 *",
} {
if _, err := ParsePGN(text); err != nil {
t.Errorf("%s: %v", name, err)
}
}
}
func TestParsePGNRefusesWeirdness(t *testing.T) {
for name, text := range map[string]string{
"empty": "",
"no moves": "[Event \"x\"]\n\n*",
"two games": "1. e4 e5 1-0\n\n1. d4 d5 *",
"text after result": "1. e4 e5 * and then",
"unterminated comment": "1. e4 {oops e5",
"unterminated variation": "1. e4 (1. d4 e5",
"stray paren": "1. e4 ) e5",
"stray brace": "1. e4 } e5",
"bad token": "1. e4 e5 2. Nf3 hello",
"bad move number": "1x. e4",
"long token": "1. " + strings.Repeat("e", 40),
"html": "1. e4 <script>alert(1)</script>",
"bad tag name": "[Ev ent \"x\"]\n\n1. e4 *",
"unquoted tag": "[Event x]\n\n1. e4 *",
"unescaped quote": "[Event \"a\"b\"]\n\n1. e4 *",
"bad escape": "[Event \"a\\n\"]\n\n1. e4 *",
"duplicate tag": "[Event \"x\"]\n[Event \"y\"]\n\n1. e4 *",
"result mismatch": "[Result \"1-0\"]\n\n1. e4 e5 0-1",
"bad result tag": "[Result \"won\"]\n\n1. e4 e5",
"control character": "1. e4\x07 e5",
"invalid utf8": "1. e4 \xff e5",
"too many plies": strings.Repeat("Nf3 Nf6 Ng1 Ng8 ", MaxPGNPlies/4+1),
"deep variations": strings.Repeat("(", maxVariationDepth+1) + "1. e4" + strings.Repeat(")", maxVariationDepth+1),
"long tag value": "[Event \"" + strings.Repeat("x", maxPGNTagValue+1) + "\"]\n\n1. e4 *",
"nag without digits": "1. e4 $ e5",
"trailing garbage in tag": "[Event \"x\"] junk\n\n1. e4 *",
} {
_, err := ParsePGN(text)
if !errors.Is(err, ErrInvalidPGN) {
t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err)
}
}
if _, err := ParsePGN(strings.Repeat("1. e4 e5 ", MaxPGNSize)); !errors.Is(err, ErrInvalidPGN) {
t.Errorf("oversized: %v", err)
}
}
// Exported games read back unchanged.
func TestParsePGNRoundTripsExport(t *testing.T) {
line, err := Replay(StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5", "a7a6", "b5a4", "g8f6", "e1g1"})
if err != nil {
t.Fatal(err)
}
text := PGN{
Tags: []Tag{{Name: "Event", Value: "Round trip \"test\""}, {Name: "White", Value: "a"}, {Name: "Black", Value: "b"}},
StartFEN: StartFEN, SAN: line.SAN, Comment: "Still going.", Result: "*",
}.String()
game, err := ParsePGN(text)
if err != nil {
t.Fatalf("%v\n%s", err, text)
}
if strings.Join(game.SAN, " ") != strings.Join(line.SAN, " ") || game.Tag("Event") != `Round trip "test"` || game.Result != "*" {
t.Errorf("round trip: %v %q", game.SAN, game.Tag("Event"))
}
}
func FuzzParsePGN(f *testing.F) {
f.Add(scholarsMate)
f.Add("1. e4 e5 (1... c5) {x} $1 2. Nf3 *")
f.Add("[A \"b\"]\n\n")
f.Fuzz(func(t *testing.T, text string) {
game, err := ParsePGN(text)
if err != nil {
if !errors.Is(err, ErrInvalidPGN) {
t.Fatalf("error %v is not ErrInvalidPGN", err)
}
return
}
if len(game.SAN) == 0 || len(game.SAN) > MaxPGNPlies {
t.Fatalf("accepted %d plies", len(game.SAN))
}
for _, san := range game.SAN {
if !pgnSAN.MatchString(san) {
t.Fatalf("accepted token %q", san)
}
}
})
}
+30 -2
View File
@@ -82,9 +82,30 @@ type BoardResponse struct {
Board string `json:"board"` // ASCII representation
}
// 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"`
}
+86
View File
@@ -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 == "" {
+2
View File
@@ -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)
+2
View File
@@ -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:
+10 -1
View File
@@ -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},
+6
View File
@@ -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),
})
}
+250
View File
@@ -0,0 +1,250 @@
package service
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
"github.com/google/uuid"
)
// ErrInvalidPGN wraps chess.ErrInvalidPGN and move-legality failures of an
// import; the message says what was wrong.
var ErrInvalidPGN = chess.ErrInvalidPGN
// Imported is what ImportGame reports.
type Imported struct {
GameID string
Plies int
Result string // stored result, "" while unfinished
}
// ImportGame stores a game read from PGN for userID: tags give the names,
// event, site, round and date; every move is replayed by the rules core and
// refused if illegal; the result comes from the PGN's result token, its
// termination from the final position (mate, stalemate, a dead position,
// repetition or the fifty-move rule) or else resignation or agreement.
func (s *Service) ImportGame(userID, text string) (*Imported, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
if uuid.Validate(userID) != nil {
return nil, errors.New("an import needs a signed-in user")
}
game, err := chess.ParsePGN(text)
if err != nil {
return nil, err
}
startFEN := chess.StartFEN
if fen := game.Tag("FEN"); fen != "" || game.Tag("SetUp") == "1" {
if fen == "" {
return nil, fmt.Errorf("%w: SetUp without a FEN tag", ErrInvalidPGN)
}
normalized, err := chess.NormalizeFEN(fen)
if err != nil {
return nil, fmt.Errorf("%w: FEN tag: %v", ErrInvalidPGN, err)
}
startFEN = normalized
}
// Replay the moves, keeping the position after each ply.
pos, err := chess.ParseFEN(startFEN)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPGN, err)
}
gameID := uuid.NewString()
start := importStartTime(game)
moves := make([]storage.MoveRecord, 0, len(game.SAN))
positions := []*chess.Position{pos}
for i, san := range game.SAN {
m, err := pos.ParseSAN(san)
if err != nil {
return nil, fmt.Errorf("%w: ply %d (%s): %v", ErrInvalidPGN, i+1, san, err)
}
color := "w"
if pos.Turn() == chess.Black {
color = "b"
}
pos = pos.Play(m)
positions = append(positions, pos)
moves = append(moves, storage.MoveRecord{
GameID: gameID, MoveNumber: i + 1, MoveUCI: m.UCI(), FENAfterMove: pos.FEN(),
PlayerColor: color, MoveTimeUTC: start,
})
}
result, termination, err := importOutcome(game.Result, positions)
if err != nil {
return nil, err
}
record := storage.GameRecord{
GameID: gameID,
InitialFEN: startFEN,
WhitePlayerID: uuid.NewString(),
WhiteType: int(core.PlayerHuman),
WhiteName: importName(game.Tag("White")),
BlackPlayerID: uuid.NewString(),
BlackType: int(core.PlayerHuman),
BlackName: importName(game.Tag("Black")),
Result: result,
Termination: termination,
StartTimeUTC: start,
ImportedBy: userID,
Event: importTag(game.Tag("Event"), 200),
Site: importTag(game.Tag("Site"), 200),
Round: importTag(game.Tag("Round"), 40),
}
if result != "" {
end := start
record.EndTimeUTC = &end
}
if err := s.store.ImportGame(record, moves); err != nil {
return nil, fmt.Errorf("%w: import game: %v", ErrStorageUnavailable, err)
}
slog.Info("game imported", "game_id", gameID, "user_id", userID, "plies", len(moves), "result", result)
return &Imported{GameID: gameID, Plies: len(moves), Result: result}, nil
}
// importOutcome maps the PGN result to the stored result and a termination
// the final position supports.
func importOutcome(token string, positions []*chess.Position) (result, termination string, err error) {
final := positions[len(positions)-1]
mate := final.InCheck() && !final.HasLegalMoves()
stalemate := !final.InCheck() && !final.HasLegalMoves()
switch token {
case "1-0", "0-1":
result = "white_wins"
if token == "0-1" {
result = "black_wins"
}
// A decisive result against the side to move at mate; otherwise the
// loser resigned (or lost on time, which the schema has no word for).
if mate && ((token == "1-0") == (final.Turn() == chess.Black)) {
return result, string(core.TermCheckmate), nil
}
if mate || stalemate {
return "", "", fmt.Errorf("%w: result %s does not match the final position", ErrInvalidPGN, token)
}
return result, string(core.TermResignation), nil
case "1/2-1/2":
switch {
case mate:
return "", "", fmt.Errorf("%w: result 1/2-1/2 but the final position is checkmate", ErrInvalidPGN)
case stalemate:
return "stalemate", string(core.TermStalemate), nil
case final.InsufficientMaterial():
return "draw", string(core.TermInsufficientMaterial), nil
case chess.Repetitions(positions) >= 3:
return "draw", string(core.TermThreefoldRepetition), nil
case final.Halfmove() >= 100:
return "draw", string(core.TermFiftyMoveRule), nil
}
return "draw", string(core.TermAgreement), nil
}
if mate || stalemate {
return "", "", fmt.Errorf("%w: the game is over but the result is *", ErrInvalidPGN)
}
return "", "", nil
}
// importName is a player name from a tag: "?" and empty mean unknown.
func importName(value string) string {
value = strings.TrimSpace(value)
if value == "?" || value == "" {
return ""
}
if utf8.RuneCountInString(value) > 64 {
value = string([]rune(value)[:64])
}
return value
}
func importTag(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "?" || value == "-" && limit == 40 || value == "" {
return ""
}
if utf8.RuneCountInString(value) > limit {
value = string([]rune(value)[:limit])
}
return value
}
// importStartTime reads UTCDate/UTCTime, else Date, else now. Unknown parts
// ("????.??.??") fall back to the current time.
func importStartTime(game *chess.ParsedPGN) time.Time {
now := time.Now().UTC()
date := game.Tag("UTCDate")
if date == "" {
date = game.Tag("Date")
}
parts := strings.Split(date, ".")
if len(parts) != 3 {
return now
}
year, err1 := strconv.Atoi(parts[0])
month, err2 := strconv.Atoi(parts[1])
day, err3 := strconv.Atoi(parts[2])
if err1 != nil || year < 1400 || year > now.Year()+1 {
return now
}
if err2 != nil || month < 1 || month > 12 {
month = 1
}
if err3 != nil || day < 1 || day > 31 {
day = 1
}
hour, minute, second := 0, 0, 0
if clock := game.Tag("UTCTime"); clock != "" {
if t, err := time.Parse("15:04:05", clock); err == nil {
hour, minute, second = t.Clock()
}
}
t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.UTC)
if t.After(now.Add(24 * time.Hour)) {
return now
}
return t
}
// RemoveGame removes a stored game from the user's list: the game is deleted
// when the user alone holds it, or the user's claim is released when another
// user claims a side (see storage.RemoveGameForUser). A deleted game is also
// unloaded from memory; a released one keeps playing for the other side.
func (s *Service) RemoveGame(gameID, userID string) (storage.RemovalOutcome, error) {
if s.store == nil {
return "", ErrStorageDisabled
}
outcome, err := s.store.RemoveGameForUser(gameID, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
return "", fmt.Errorf("%w: remove game: %v", ErrStorageUnavailable, err)
}
s.mu.Lock()
if g, ok := s.games[gameID]; ok {
switch outcome {
case storage.RemovedGame:
delete(s.games, gameID)
case storage.RemovedClaim:
g.ReleaseUser(userID)
}
}
s.mu.Unlock()
if outcome == storage.RemovedGame {
s.waiter.RemoveGame(gameID)
}
slog.Info("game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome)
return outcome, nil
}
+179
View File
@@ -0,0 +1,179 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
)
const importPGN = `[Event "World Championship"]
[Site "Reykjavik"]
[Date "1972.07.11"]
[Round "1"]
[White "Spassky, Boris"]
[Black "Fischer, Robert"]
[Result "1-0"]
1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0
`
func TestImportGameStoresReplaysAndLists(t *testing.T) {
svc := newPersistentTestService(t)
user, _, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
imported, err := svc.ImportGame(user.UserID, importPGN)
if err != nil {
t.Fatal(err)
}
if imported.Plies != 6 || imported.Result != "white_wins" {
t.Fatalf("imported = %+v", imported)
}
history, err := svc.GetGameHistory(imported.GameID)
if err != nil {
t.Fatal(err)
}
if history.Origin != "imported" || history.Event != "World Championship" || history.Site != "Reykjavik" ||
history.Round != "1" || history.Players.White.Name != "Spassky, Boris" || history.Players.Black.Name != "Fischer, Robert" ||
history.Termination != "resignation" || history.PGNResult != "1-0" ||
history.StartTimeUTC.Format("2006-01-02") != "1972-07-11" {
t.Fatalf("history = %+v", history)
}
var san []string
for _, m := range history.Moves {
san = append(san, m.SAN)
}
if strings.Join(san, " ") != "d4 Nf6 c4 e6 Nf3 d5" {
t.Errorf("SAN = %v", san)
}
pgn, err := svc.GetGamePGN(imported.GameID, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`[Event "World Championship"]`, `[Site "Reykjavik"]`, `[Round "1"]`, `[White "Spassky, Boris"]`, "1. d4 Nf6 2. c4 e6 3. Nf3 d5 { White wins by resignation. } 1-0"} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
page, err := svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != imported.GameID || page.Games[0].Origin != "imported" ||
page.Games[0].Event != "World Championship" || page.Games[0].MoveCount != 6 {
t.Fatalf("listing = %+v", page.Games)
}
// Imported games have no claimed colour.
page, _ = svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10, Color: "white"})
if len(page.Games) != 0 {
t.Errorf("imported game listed under a colour filter")
}
// Outcomes from the final position.
for name, tc := range map[string]struct{ pgn, result, termination string }{
"mate": {"1. f3 e5 2. g4 Qh4# 0-1", "black_wins", "checkmate"},
"stalemate": {"[FEN \"7k/8/6K1/5Q2/8/8/8/8 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Qf7 1/2-1/2", "stalemate", "stalemate"},
"agreed": {"1. e4 e5 1/2-1/2", "draw", "agreement"},
"ongoing": {"1. e4 e5 *", "", ""},
"material": {"[FEN \"4k3/8/8/8/8/8/8/3RK3 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Rd8+ Kxd8 1/2-1/2", "draw", "insufficient_material"},
} {
got, err := svc.ImportGame(user.UserID, tc.pgn)
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
h, _ := svc.GetGameHistory(got.GameID)
if h.Result != tc.result || h.Termination != tc.termination {
t.Errorf("%s: result %q %q, want %q %q", name, h.Result, h.Termination, tc.result, tc.termination)
}
}
// Illegal moves and impossible results are refused.
for name, bad := range map[string]string{
"illegal": "1. e4 e5 2. Ke2 Ke7 3. Kd3 Qh4",
"mate but draw": "1. f3 e5 2. g4 Qh4# 1/2-1/2",
"mate but white": "1. f3 e5 2. g4 Qh4# 1-0",
"over but star": "1. f3 e5 2. g4 Qh4# *",
"bad fen": "[FEN \"nonsense\"]\n[SetUp \"1\"]\n\n1. e4 *",
"not pgn": "hello world",
} {
if _, err := svc.ImportGame(user.UserID, bad); !errors.Is(err, ErrInvalidPGN) {
t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err)
}
}
}
func TestRemoveGameDeletesOwnAndReleasesShared(t *testing.T) {
svc := newPersistentTestService(t)
alice, _, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
bob, _, err := svc.RegisterUser("bob", "", "Password1")
if err != nil {
t.Fatal(err)
}
human := core.PlayerConfig{Type: core.PlayerHuman}
// A game alice alone claims: deleted outright and unloaded.
own := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing)
outcome, err := svc.RemoveGame(own, alice.UserID)
if err != nil || outcome != storage.RemovedGame {
t.Fatalf("own game: %v %v", outcome, err)
}
if _, err := svc.GetGameView(own); err == nil {
t.Error("deleted game still in memory")
}
if _, err := svc.GetGameHistory(own); !errors.Is(err, ErrGameNotFound) {
t.Errorf("deleted game history: %v", err)
}
// An imported game: deleted by its importer, unknown to anyone else.
imported, err := svc.ImportGame(alice.UserID, "1. e4 e5 *")
if err != nil {
t.Fatal(err)
}
if _, err := svc.RemoveGame(imported.GameID, bob.UserID); !errors.Is(err, ErrGameNotFound) {
t.Errorf("bob removing alice's import: %v", err)
}
if outcome, err := svc.RemoveGame(imported.GameID, alice.UserID); err != nil || outcome != storage.RemovedGame {
t.Errorf("alice removing her import: %v %v", outcome, err)
}
// A game both claim: alice's removal releases her side; bob keeps it.
shared := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing)
if err := svc.ApplyMoveWithState(shared, MoveCommit{
ExpectedFEN: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", ExpectedState: core.StateOngoing,
ExpectedTurn: core.ColorBlack, ActorUserID: bob.UserID, MoveUCI: "e7e5",
NewFEN: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
outcome, err = svc.RemoveGame(shared, alice.UserID)
if err != nil || outcome != storage.RemovedClaim {
t.Fatalf("shared game: %v %v", outcome, err)
}
view, err := svc.GetGameView(shared)
if err != nil || view.WhitePlayer.ClaimedBy != "" || view.BlackPlayer.ClaimedBy != bob.UserID {
t.Errorf("shared game after release: %v %+v", err, view.WhitePlayer)
}
history, _ := svc.GetGameHistory(shared)
if history.Players.White.ClaimedBy != "" || history.Players.White.Name != "" || history.Players.Black.Name != "bob" {
t.Errorf("stored claims after release: %+v %+v", history.Players.White, history.Players.Black)
}
if page, _ := svc.GetUserGames(alice.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 0 {
t.Errorf("alice still lists %d games", len(page.Games))
}
if page, _ := svc.GetUserGames(bob.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 1 {
t.Errorf("bob lists %d games", len(page.Games))
}
if _, err := svc.RemoveGame(shared, alice.UserID); !errors.Is(err, ErrGameNotFound) {
t.Errorf("second removal: %v", err)
}
}
+11 -4
View File
@@ -17,7 +17,8 @@ const gameSelectColumns = `
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
g.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
}
+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
)
// ImportGame stores a game read from PGN with its whole move list in one
// transaction, behind every gameplay write already queued, and returns once
// it has committed. The record's ImportedBy names the owner; names, event,
// site and round come from the PGN's tags.
func (s *Store) ImportGame(record GameRecord, moves []MoveRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required")
}
if !validUUID(record.ImportedBy) {
return errors.New("an imported game needs its importer")
}
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err
}
for i, move := range moves {
if move.GameID != record.GameID || move.MoveNumber != i+1 || move.MoveUCI == "" || move.FENAfterMove == "" ||
(move.PlayerColor != "w" && move.PlayerColor != "b") {
return fmt.Errorf("move %d is malformed", i+1)
}
}
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
return s.enqueueWait(ctx, "import_game", func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
start_time_utc, result, termination, end_time_utc,
imported_by, event, site, round
) VALUES ($1, $2, $3, $4, 0, 0, NULL, $5, $6, $7, 0, 0, NULL, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, nullableString(record.WhiteName),
record.BlackPlayerID, record.BlackType, nullableString(record.BlackName),
record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination), record.EndTimeUTC,
record.ImportedBy, nullableString(record.Event), nullableString(record.Site), nullableString(record.Round),
); err != nil {
return err
}
for _, move := range moves {
if _, err := tx.ExecContext(ctx, `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES ($1, $2, $3, $4, $5, $6)`,
move.GameID, move.MoveNumber, move.MoveUCI, move.FENAfterMove, move.PlayerColor, move.MoveTimeUTC,
); err != nil {
return err
}
}
slog.Debug("storage game imported", "game_id", record.GameID, "plies", len(moves), "user_id", record.ImportedBy)
return nil
})
}
// RemovalOutcome says what RemoveGameForUser did.
type RemovalOutcome string
const (
// RemovedGame: the game and its moves are gone.
RemovedGame RemovalOutcome = "game"
// RemovedClaim: another user still claims a side, so only the caller's
// claim and name were cleared; the game stays with the other player.
RemovedClaim RemovalOutcome = "claim"
)
// RemoveGameForUser deletes a stored game the user alone holds (as sole
// claimant or importer), or, when another user claims a side, releases the
// user's own claim so the game leaves their list but not the other's. It
// returns sql.ErrNoRows when the user holds nothing in the game. Ordered
// behind queued writes, it returns once committed.
func (s *Store) RemoveGameForUser(gameID, userID string) (RemovalOutcome, error) {
if !validUUID(gameID) || !validUUID(userID) {
return "", sql.ErrNoRows
}
var outcome RemovalOutcome
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
// A missing row or a game the user holds nothing in is an answer, not a
// failed write: the transaction then changes nothing and outcome stays
// empty, so the writer is never degraded by it.
err := s.enqueueWait(ctx, "remove_game_for_user", func(ctx context.Context, tx *sql.Tx) error {
var white, black, importer sql.NullString
err := tx.QueryRowContext(ctx,
`SELECT white_claimed_by, black_claimed_by, imported_by FROM games WHERE game_id = $1 FOR UPDATE`,
gameID).Scan(&white, &black, &importer)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if white.String != userID && black.String != userID && importer.String != userID {
return nil
}
others := (white.Valid && white.String != userID) || (black.Valid && black.String != userID)
if others {
_, err := tx.ExecContext(ctx, `UPDATE games SET
white_claimed_by = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_name END
WHERE game_id = $1`, gameID, userID)
outcome = RemovedClaim
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = $1`, gameID)
outcome = RemovedGame
return err
})
if err != nil {
return "", err
}
if outcome == "" {
return "", sql.ErrNoRows
}
slog.Debug("storage game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome)
return outcome, nil
}
+6 -5
View File
@@ -62,9 +62,10 @@ func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) {
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// 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)
+32 -1
View File
@@ -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;
`,
}
+1 -1
View File
@@ -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"},
+3 -2
View File
@@ -150,8 +150,9 @@ func (s *Store) DeleteAccount(userID string) error {
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
white_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; }