251 lines
7.6 KiB
Go
251 lines
7.6 KiB
Go
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
|
|
}
|