v0.14.0 web replay UI, My games, CLI replay
This commit is contained in:
37 files changed
+4058
-1803
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/client/api"
|
||||
)
|
||||
|
||||
func TestNavigationClampsAndSelectsStoredFEN(t *testing.T) {
|
||||
h := &api.GameHistoryResponse{
|
||||
InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
Moves: []api.HistoryMove{
|
||||
{MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "fen1", PlayerColor: "w"},
|
||||
{MoveNumber: 2, MoveUCI: "c7c5", SAN: "c5", FENAfterMove: "fen2", PlayerColor: "b"},
|
||||
{MoveNumber: 3, MoveUCI: "g1f3", SAN: "Nf3", FENAfterMove: "fen3", PlayerColor: "w"},
|
||||
},
|
||||
}
|
||||
s := New(h)
|
||||
if s.Ply != 0 || s.FEN() != h.InitialFEN || s.Label(0) != "start" {
|
||||
t.Fatalf("new state: ply %d fen %q label %q", s.Ply, s.FEN(), s.Label(0))
|
||||
}
|
||||
if got := s.Next(); got != 1 || s.FEN() != "fen1" || s.Label(1) != "1. e4" {
|
||||
t.Errorf("next: ply %d fen %q label %q", got, s.FEN(), s.Label(1))
|
||||
}
|
||||
if got := s.Last(); got != 3 || !s.AtEnd() || s.Label(3) != "2. Nf3" {
|
||||
t.Errorf("last: ply %d atEnd %v label %q", got, s.AtEnd(), s.Label(3))
|
||||
}
|
||||
if got := s.Next(); got != 3 {
|
||||
t.Errorf("next past the end = %d", got)
|
||||
}
|
||||
if got := s.Goto(-5); got != 0 || s.FEN() != h.InitialFEN {
|
||||
t.Errorf("goto -5 = %d", got)
|
||||
}
|
||||
if got := s.Goto(2); got != 2 || s.Label(2) != "1... c5" || s.FEN() != "fen2" {
|
||||
t.Errorf("goto 2 = %d label %q", got, s.Label(2))
|
||||
}
|
||||
if got := s.Prev(); got != 1 {
|
||||
t.Errorf("prev = %d", got)
|
||||
}
|
||||
if s.Move(0) != nil || s.Move(4) != nil || s.Move(3).SAN != "Nf3" {
|
||||
t.Error("Move bounds")
|
||||
}
|
||||
want := "1. e4 c5 2. [Nf3]"
|
||||
s.Last()
|
||||
if got := s.Movetext(80); got != want {
|
||||
t.Errorf("movetext = %q, want %q", got, want)
|
||||
}
|
||||
if got := s.Movetext(8); got != "1. e4 c5\n2. [Nf3]" {
|
||||
t.Errorf("wrapped movetext = %q", got)
|
||||
}
|
||||
if s.Result() != "ongoing" {
|
||||
t.Errorf("result = %q", s.Result())
|
||||
}
|
||||
h.Result, h.PGNResult, h.Termination = "white_wins", "1-0", "checkmate"
|
||||
if s.Result() != "1-0 (checkmate)" {
|
||||
t.Errorf("result = %q", s.Result())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomStartNumbersFromItsMoveAndSide(t *testing.T) {
|
||||
h := &api.GameHistoryResponse{
|
||||
InitialFEN: "4k3/8/8/8/8/8/8/4K2R b K - 0 40",
|
||||
Moves: []api.HistoryMove{
|
||||
{MoveNumber: 1, MoveUCI: "e8d8", SAN: "Kd8", FENAfterMove: "f1", PlayerColor: "b"},
|
||||
{MoveNumber: 2, MoveUCI: "e1g1", SAN: "O-O", FENAfterMove: "f2", PlayerColor: "w"},
|
||||
{MoveNumber: 3, MoveUCI: "d8c8", SAN: "Kc8", FENAfterMove: "f3", PlayerColor: "b"},
|
||||
},
|
||||
}
|
||||
s := New(h)
|
||||
for ply, want := range map[int]string{1: "40... Kd8", 2: "41. O-O", 3: "41... Kc8"} {
|
||||
if got := s.Label(ply); got != want {
|
||||
t.Errorf("label %d = %q, want %q", ply, got, want)
|
||||
}
|
||||
}
|
||||
s.Goto(2)
|
||||
if got := s.Movetext(80); got != "40... Kd8 41. [O-O] Kc8" {
|
||||
t.Errorf("movetext = %q", got)
|
||||
}
|
||||
if w, b := s.Players(); w != "Anonymous" || b != "Anonymous" {
|
||||
t.Errorf("players = %q %q", w, b)
|
||||
}
|
||||
h.Players.White = api.PlayerInfo{Type: 2, Level: 7}
|
||||
h.Players.Black = api.PlayerInfo{Type: 1, Name: "alice"}
|
||||
if w, b := s.Players(); w != "Stockfish L7" || b != "alice" {
|
||||
t.Errorf("players = %q %q", w, b)
|
||||
}
|
||||
empty := New(&api.GameHistoryResponse{InitialFEN: h.InitialFEN})
|
||||
if empty.Movetext(80) != "(no moves)" || empty.Last() != 0 {
|
||||
t.Error("empty history")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user