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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user