Files
chess/internal/client/replay/replay.go
T
lixen dbe7283ca2
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s
v0.14.0 web replay UI, My games, CLI replay
2026-10-19 16:29:01 -04:00

176 lines
4.2 KiB
Go

// Package replay steps through a stored game's history in the terminal
// client. The state is separate from the live session: it holds the
// history the server returned and a ply, and renders positions from the
// stored FENs; it never computes a move.
package replay
import (
"fmt"
"strconv"
"strings"
"github.com/lixenwraith/chess/internal/client/api"
)
// State is a stored game and the ply shown: 0 is the initial position,
// Len() the position after the last move.
type State struct {
History *api.GameHistoryResponse
Ply int
}
// New starts a replay at the initial position.
func New(history *api.GameHistoryResponse) *State {
return &State{History: history}
}
// Len is the number of stored plies.
func (s *State) Len() int { return len(s.History.Moves) }
// Goto shows the position after ply plies, clamped to the stored line, and
// returns the ply shown.
func (s *State) Goto(ply int) int {
if ply < 0 {
ply = 0
}
if ply > s.Len() {
ply = s.Len()
}
s.Ply = ply
return s.Ply
}
func (s *State) Next() int { return s.Goto(s.Ply + 1) }
func (s *State) Prev() int { return s.Goto(s.Ply - 1) }
func (s *State) First() int { return s.Goto(0) }
func (s *State) Last() int { return s.Goto(s.Len()) }
// AtEnd reports whether the last stored position is shown.
func (s *State) AtEnd() bool { return s.Ply >= s.Len() }
// FEN is the stored position at the current ply.
func (s *State) FEN() string {
if s.Ply == 0 {
return s.History.InitialFEN
}
return s.History.Moves[s.Ply-1].FENAfterMove
}
// Move is the ply-th move (from 1), or nil for the initial position.
func (s *State) Move(ply int) *api.HistoryMove {
if ply < 1 || ply > s.Len() {
return nil
}
return &s.History.Moves[ply-1]
}
// startsWithBlack and firstMoveNumber come from the initial FEN, so a game
// from a custom position numbers its moves as its PGN does.
func (s *State) start() (number int, black bool) {
parts := strings.Fields(s.History.InitialFEN)
number = 1
if len(parts) >= 6 {
if n, err := strconv.Atoi(parts[5]); err == nil && n > 0 {
number = n
}
}
black = len(parts) >= 2 && parts[1] == "b"
return number, black
}
// Label names a ply in PGN style: "2. Nf3", "2... Nf6", or "start".
func (s *State) Label(ply int) string {
move := s.Move(ply)
if move == nil {
return "start"
}
first, black := s.start()
offset := ply - 1
if black {
offset++
}
number := first + offset/2
text := move.SAN
if text == "" {
text = move.MoveUCI
}
if move.PlayerColor == "b" {
return fmt.Sprintf("%d... %s", number, text)
}
return fmt.Sprintf("%d. %s", number, text)
}
// Movetext lists the moves with numbers, the current ply in brackets,
// wrapped at width columns (80 when width is not positive).
func (s *State) Movetext(width int) string {
if width <= 0 {
width = 80
}
first, black := s.start()
var tokens []string
number := first
for i, move := range s.History.Moves {
text := move.SAN
if text == "" {
text = move.MoveUCI
}
if i+1 == s.Ply {
text = "[" + text + "]"
}
switch {
case i == 0 && black:
tokens = append(tokens, fmt.Sprintf("%d...", number), text)
number++
case move.PlayerColor == "w":
tokens = append(tokens, fmt.Sprintf("%d.", number), text)
default:
tokens = append(tokens, text)
number++
}
}
if len(tokens) == 0 {
return "(no moves)"
}
var b strings.Builder
col := 0
for _, token := range tokens {
if col > 0 && col+1+len(token) > width {
b.WriteByte('\n')
col = 0
} else if col > 0 {
b.WriteByte(' ')
col++
}
b.WriteString(token)
col += len(token)
}
return b.String()
}
// Players names the sides: the stored name, the engine and level, or
// "Anonymous".
func (s *State) Players() (white, black string) {
return playerLabel(s.History.Players.White), playerLabel(s.History.Players.Black)
}
func playerLabel(p api.PlayerInfo) string {
switch {
case p.Type == 2:
return fmt.Sprintf("Stockfish L%d", p.Level)
case p.Name != "":
return p.Name
}
return "Anonymous"
}
// Result describes the outcome: "1-0 (checkmate)" or "ongoing".
func (s *State) Result() string {
if s.History.Result == "" {
return "ongoing"
}
if s.History.Termination != "" {
return fmt.Sprintf("%s (%s)", s.History.PGNResult, strings.ReplaceAll(s.History.Termination, "_", " "))
}
return s.History.PGNResult
}