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