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

+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)
}
}