Files
lixen a012d07cd8
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
v0.16.2 PGN site, web client panel update
2026-10-20 01:39:53 -04:00

193 lines
5.8 KiB
Go

// Package replay works on stored games: it derives SAN for their moves,
// exports them as PGN, and checks them against the rules. It reads the
// records that storage returns and never writes.
package replay
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
)
var (
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
ErrPlyOutOfRange = errors.New("ply out of range")
// ErrNotation reports a stored move the rules core cannot notate.
ErrNotation = errors.New("cannot derive notation")
)
// Notate derives SAN for each stored move from the stored position before
// it, so one unparsable ply does not affect the others. Plies it cannot
// notate are left empty and the first failure is returned.
func Notate(initialFEN string, moves []storage.MoveRecord) ([]string, error) {
san := make([]string, len(moves))
before := initialFEN
var firstErr error
for i, move := range moves {
pos, err := chess.ParseFEN(before)
if err == nil {
var m chess.Move
if m, err = pos.ParseUCI(move.MoveUCI); err == nil {
san[i] = pos.SAN(m)
}
}
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%w: ply %d (%s): %v", ErrNotation, move.MoveNumber, move.MoveUCI, err)
}
before = move.FENAfterMove
}
return san, firstErr
}
// PGN is an export and its suggested download name.
type PGN struct {
Text string
Filename string
}
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
// first ply moves, with result "*" and no termination when that stops short
// of the end of the line. site is the Site tag of a game played on this
// server (PGN's "?" when empty); an imported game keeps its own.
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int, site string) (*PGN, error) {
if ply > len(moves) {
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
}
san, err := Notate(record.InitialFEN, moves)
if err != nil {
return nil, err
}
complete := ply < 0 || ply == len(moves)
if !complete {
san = san[:ply]
}
result, comment := "*", ""
if complete {
result = chess.ResultToken(record.Result)
comment = Describe(record.Result, record.Termination)
if note := continuedAfter(record, len(moves)); note != "" {
comment = strings.TrimSpace(note + " " + comment)
}
}
start := record.StartTimeUTC.UTC()
event, 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: 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},
{Name: "UTCDate", Value: start.Format("2006.01.02")},
{Name: "UTCTime", Value: start.Format("15:04:05")},
{Name: "WhiteType", Value: playerType(record.WhiteType)},
{Name: "BlackType", Value: playerType(record.BlackType)},
{Name: "PlyCount", Value: strconv.Itoa(len(san))},
}
if complete {
// PGN's Termination values describe how play stopped; mate, draws,
// and resignation are all "normal". The reason goes in the comment.
value := "unterminated"
if record.Result != "" {
value = "normal"
}
tags = append(tags, chess.Tag{Name: "Termination", Value: value})
}
name := fmt.Sprintf("chess-%s-%s", start.Format("20060102"), record.GameID[:8])
if !complete {
name += fmt.Sprintf("-ply%d", ply)
}
return &PGN{
Text: chess.PGN{
Tags: tags, StartFEN: record.InitialFEN, SAN: san, Comment: comment, Result: result,
}.String(),
Filename: name + ".pgn",
}, nil
}
// continuedAfter notes a resignation or agreed draw that an undo took back,
// such as "White resigned at ply 24; play continued."; empty when there was
// none or it is still the game's result.
func continuedAfter(record *storage.GameRecord, plies int) string {
if record.ConcessionResult == "" {
return ""
}
if record.ConcessionResult == record.Result &&
record.ConcessionTermination == record.Termination && record.ConcessionPly == plies {
return ""
}
var event string
switch record.ConcessionResult {
case "white_wins":
event = "Black resigned"
case "black_wins":
event = "White resigned"
default:
event = "A draw was agreed"
}
return fmt.Sprintf("%s at ply %d; play continued.", event, record.ConcessionPly)
}
// Describe returns a sentence for a stored result and termination, such as
// "Black wins by resignation." or "Draw by threefold repetition."; empty for
// an unfinished game.
func Describe(result, termination string) string {
winner := ""
switch result {
case "white_wins":
winner = "White"
case "black_wins":
winner = "Black"
case "":
return ""
}
switch core.Termination(termination) {
case core.TermCheckmate:
return winner + " wins by checkmate."
case core.TermResignation:
return winner + " wins by resignation."
case core.TermStalemate:
return "Draw by stalemate."
case core.TermInsufficientMaterial:
return "Draw by insufficient material."
case core.TermThreefoldRepetition:
return "Draw by threefold repetition."
case core.TermFiftyMoveRule:
return "Draw by the fifty-move rule."
case core.TermAgreement:
return "Draw by agreement."
}
return ""
}
func playerName(playerType, level int, name string) string {
switch {
case core.PlayerType(playerType) == core.PlayerComputer:
return "Stockfish level " + strconv.Itoa(level)
case name != "":
return name
}
return "Anonymous"
}
func playerType(t int) string {
if core.PlayerType(t) == core.PlayerComputer {
return "program"
}
return "human"
}