176 lines
4.2 KiB
Go
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
|
|
}
|