v0.14.0 web replay UI, My games, CLI replay
This commit is contained in:
1 parent
87dcb3e550
commit
a999177f0c
37 files changed
+4058
-1803
No files matched your search
@@ -45,6 +45,7 @@ type GameResponse struct {
|
||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||
Concession *Concession `json:"concession,omitempty"`
|
||||
Moves []string `json:"moves"`
|
||||
SAN []string `json:"san"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
@@ -129,6 +130,7 @@ type GameHistoryResponse struct {
|
||||
Termination string `json:"termination,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins")
|
||||
Players PlayersResponse `json:"players"`
|
||||
Moves []HistoryMove `json:"moves"`
|
||||
}
|
||||
@@ -148,6 +150,7 @@ type GameSummary struct {
|
||||
FinalFEN string `json:"finalFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
PGNResult string `json:"pgnResult"`
|
||||
Termination string `json:"termination,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
|
||||
@@ -104,8 +104,8 @@ func (r *Registry) registerGameCommands() {
|
||||
r.Register(&Command{
|
||||
Name: "pgn",
|
||||
ShortName: "",
|
||||
Description: "Print a stored game as PGN",
|
||||
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
|
||||
Description: "Print or save a stored game as PGN",
|
||||
Usage: "pgn [gameId] [ply] [save <path>] (default: current or replayed game)",
|
||||
Handler: pgnHandler,
|
||||
})
|
||||
|
||||
@@ -520,6 +520,9 @@ func undoHandler(s *session.Session, args []string) error {
|
||||
}
|
||||
|
||||
func showBoardHandler(s *session.Session, args []string) error {
|
||||
if s.Replay != nil {
|
||||
return showReplayPosition(s)
|
||||
}
|
||||
gameID := s.GetCurrentGame()
|
||||
if gameID == "" {
|
||||
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
||||
@@ -692,19 +695,21 @@ func gamesHandler(s *session.Session, args []string) error {
|
||||
return err
|
||||
}
|
||||
s.GamesCursor = resp.NextCursor
|
||||
s.GamesPage = resp.Games
|
||||
if len(resp.Games) == 0 {
|
||||
display.Println(display.Yellow, "No stored games")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||
for _, g := range resp.Games {
|
||||
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
|
||||
fmt.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||
for i, g := range resp.Games {
|
||||
fmt.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID,
|
||||
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
||||
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||
}
|
||||
display.Println(display.Cyan, "\nReplay one: replay <#> or replay <gameId>")
|
||||
if resp.NextCursor != "" {
|
||||
display.Println(display.Cyan, "\nMore games: games more")
|
||||
display.Println(display.Cyan, "More games: games more")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -720,9 +725,21 @@ func playerLabel(p api.PlayerInfo) string {
|
||||
}
|
||||
|
||||
// pgnHandler prints the stored game as PGN: the current game unless an ID is
|
||||
// given, and every ply unless a count is given.
|
||||
// given, and every ply unless a count is given. During a replay the default
|
||||
// is the replayed game up to the current ply. "save <path>" writes the
|
||||
// export to a file instead of printing it.
|
||||
func pgnHandler(s *session.Session, args []string) error {
|
||||
path, args, err := pgnSaveArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gameID, ply := s.CurrentGame, -1
|
||||
if s.Replay != nil {
|
||||
gameID = s.Replay.History.GameID
|
||||
if !s.Replay.AtEnd() {
|
||||
ply = s.Replay.Ply
|
||||
}
|
||||
}
|
||||
for _, arg := range args {
|
||||
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
||||
ply = n
|
||||
@@ -737,6 +754,13 @@ func pgnHandler(s *session.Session, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path != "" {
|
||||
if err := savePGN(path, trimPGN(text)); err != nil {
|
||||
return err
|
||||
}
|
||||
display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path)
|
||||
return nil
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Print(text)
|
||||
return nil
|
||||
|
||||
@@ -32,6 +32,7 @@ func NewRegistry(s *session.Session) *Registry {
|
||||
|
||||
// Register all commands
|
||||
r.registerGameCommands()
|
||||
r.registerReplayCommands()
|
||||
r.registerAuthCommands()
|
||||
r.registerDebugCommands()
|
||||
|
||||
@@ -78,6 +79,10 @@ func (r *Registry) Execute(input string) {
|
||||
display.Println(display.Reset, "Type 'help' for available commands")
|
||||
return
|
||||
}
|
||||
if r.session.Replay != nil && liveOnly[cmd.Name] {
|
||||
display.Println(display.Red, "'%s' is not available while replaying; 'live' returns to the live session", cmd.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// Set verbose mode in client if session supports it
|
||||
if cl, ok := r.session.GetClient().(*api.Client); ok {
|
||||
@@ -132,6 +137,18 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
||||
{"pgn", "", ""},
|
||||
}
|
||||
|
||||
replayCommands := []cmdInfo{
|
||||
{"replay", "", ""},
|
||||
{"next", ">", ""},
|
||||
{"prev", "<", ""},
|
||||
{"first", "", ""},
|
||||
{"last", "", ""},
|
||||
{"goto", "", ""},
|
||||
{"auto", "", ""},
|
||||
{"fen", "", ""},
|
||||
{"live", "", ""},
|
||||
}
|
||||
|
||||
authCommands := []cmdInfo{
|
||||
{"register", "r", ""},
|
||||
{"login", "l", ""},
|
||||
@@ -163,6 +180,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
||||
}
|
||||
|
||||
printCommandGroup("Game Commands", gameCommands)
|
||||
printCommandGroup("Replay Commands", replayCommands)
|
||||
printCommandGroup("Auth Commands", authCommands)
|
||||
printCommandGroup("Utility Commands", utilCommands)
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/client/display"
|
||||
"github.com/lixenwraith/chess/internal/client/replay"
|
||||
"github.com/lixenwraith/chess/internal/client/session"
|
||||
)
|
||||
|
||||
// Replay commands step through a stored game from its history. The replay
|
||||
// state lives beside the live session, which keeps its game, user and poll
|
||||
// position; live commands that would change the game are refused until
|
||||
// 'live' ends the replay (see liveOnly).
|
||||
func (r *Registry) registerReplayCommands() {
|
||||
r.Register(&Command{
|
||||
Name: "replay",
|
||||
ShortName: "",
|
||||
Description: "Step through a stored game",
|
||||
Usage: "replay <gameId|index> (index from the last 'games' listing)",
|
||||
Handler: replayHandler,
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "next",
|
||||
ShortName: ">",
|
||||
Description: "Replay: next ply",
|
||||
Usage: "next [count]",
|
||||
Handler: stepHandler(1),
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "prev",
|
||||
ShortName: "<",
|
||||
Description: "Replay: previous ply",
|
||||
Usage: "prev [count]",
|
||||
Handler: stepHandler(-1),
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "first",
|
||||
ShortName: "",
|
||||
Description: "Replay: initial position",
|
||||
Usage: "first",
|
||||
Handler: func(s *session.Session, args []string) error {
|
||||
return gotoHandler(s, []string{"0"})
|
||||
},
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "last",
|
||||
ShortName: "",
|
||||
Description: "Replay: final position",
|
||||
Usage: "last",
|
||||
Handler: func(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
return errNotReplaying
|
||||
}
|
||||
return gotoHandler(s, []string{strconv.Itoa(s.Replay.Len())})
|
||||
},
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "goto",
|
||||
ShortName: "",
|
||||
Description: "Replay: show the position after N plies",
|
||||
Usage: "goto <ply>",
|
||||
Handler: gotoHandler,
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "auto",
|
||||
ShortName: "",
|
||||
Description: "Replay: play forward to the end",
|
||||
Usage: "auto [ms] (delay per ply, default 1000)",
|
||||
Handler: autoHandler,
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "fen",
|
||||
ShortName: "",
|
||||
Description: "Replay: print the FEN of the current position",
|
||||
Usage: "fen",
|
||||
Handler: fenHandler,
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "live",
|
||||
ShortName: "",
|
||||
Description: "Leave the replay and return to the live session",
|
||||
Usage: "live",
|
||||
Handler: liveHandler,
|
||||
})
|
||||
}
|
||||
|
||||
var errNotReplaying = fmt.Errorf("no replay in progress; use 'replay <gameId|index>'")
|
||||
|
||||
// liveOnly names the commands a replay refuses: they would change the live
|
||||
// game or poll it, while a replay only reads a stored one.
|
||||
var liveOnly = map[string]bool{
|
||||
"new": true, "join": true, "move": true, "computer": true, "undo": true,
|
||||
"delete": true, "poll": true, "resign": true, "draw": true,
|
||||
}
|
||||
|
||||
// replayHandler loads a stored game by ID, or by its position in the last
|
||||
// 'games' listing, and shows its initial position.
|
||||
func replayHandler(s *session.Session, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("usage: replay <gameId|index>")
|
||||
}
|
||||
gameID := args[0]
|
||||
if index, err := strconv.Atoi(gameID); err == nil {
|
||||
if index < 1 || index > len(s.GamesPage) {
|
||||
if len(s.GamesPage) == 0 {
|
||||
return fmt.Errorf("no listing to index; run 'games' first or give a game ID")
|
||||
}
|
||||
return fmt.Errorf("index %d is outside the last listing (1-%d)", index, len(s.GamesPage))
|
||||
}
|
||||
gameID = s.GamesPage[index-1].GameID
|
||||
}
|
||||
|
||||
history, err := s.Client.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Replay = replay.New(history)
|
||||
|
||||
white, black := s.Replay.Players()
|
||||
display.Println(display.Cyan, "\nReplaying %s", history.GameID)
|
||||
fmt.Printf("%s vs %s · %s · %d plies · started %s\n",
|
||||
display.C(display.Blue, white), display.C(display.Red, black), s.Replay.Result(), s.Replay.Len(),
|
||||
history.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||
if c := history.Concession; c != nil {
|
||||
what := "a draw was agreed"
|
||||
switch c.Result {
|
||||
case "white_wins":
|
||||
what = "Black resigned"
|
||||
case "black_wins":
|
||||
what = "White resigned"
|
||||
}
|
||||
display.Println(display.Yellow, "%s at ply %d; play continued.", what, c.Ply)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(s.Replay.Movetext(80))
|
||||
display.Println(display.Reset, "\nnext, prev, first, last, goto <ply>, auto [ms], fen, pgn [save <path>], live")
|
||||
return showReplayPosition(s)
|
||||
}
|
||||
|
||||
func stepHandler(direction int) func(*session.Session, []string) error {
|
||||
return func(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
return errNotReplaying
|
||||
}
|
||||
count := 1
|
||||
if len(args) > 0 {
|
||||
n, err := strconv.Atoi(args[0])
|
||||
if err != nil || n < 1 {
|
||||
return fmt.Errorf("invalid count: %s", args[0])
|
||||
}
|
||||
count = n
|
||||
}
|
||||
before := s.Replay.Ply
|
||||
s.Replay.Goto(before + direction*count)
|
||||
if s.Replay.Ply == before {
|
||||
if direction > 0 {
|
||||
display.Println(display.Yellow, "At the end of the game")
|
||||
} else {
|
||||
display.Println(display.Yellow, "At the initial position")
|
||||
}
|
||||
}
|
||||
return showReplayPosition(s)
|
||||
}
|
||||
}
|
||||
|
||||
func gotoHandler(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
return errNotReplaying
|
||||
}
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("usage: goto <ply> (0-%d)", s.Replay.Len())
|
||||
}
|
||||
ply, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ply: %s", args[0])
|
||||
}
|
||||
if ply < 0 || ply > s.Replay.Len() {
|
||||
return fmt.Errorf("ply %d is outside 0-%d", ply, s.Replay.Len())
|
||||
}
|
||||
s.Replay.Goto(ply)
|
||||
return showReplayPosition(s)
|
||||
}
|
||||
|
||||
// autoHandler plays forward from the current ply to the end, one board per
|
||||
// ply, pausing between them.
|
||||
func autoHandler(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
return errNotReplaying
|
||||
}
|
||||
delay := 1000
|
||||
if len(args) > 0 {
|
||||
n, err := strconv.Atoi(args[0])
|
||||
if err != nil || n < 0 || n > 60000 {
|
||||
return fmt.Errorf("delay must be 0-60000 ms")
|
||||
}
|
||||
delay = n
|
||||
}
|
||||
if s.Replay.AtEnd() {
|
||||
display.Println(display.Yellow, "At the end of the game; 'first' to start over")
|
||||
return nil
|
||||
}
|
||||
for !s.Replay.AtEnd() {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
s.Replay.Next()
|
||||
if err := showReplayPosition(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fenHandler(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
return errNotReplaying
|
||||
}
|
||||
fmt.Println(s.Replay.FEN())
|
||||
return nil
|
||||
}
|
||||
|
||||
func liveHandler(s *session.Session, args []string) error {
|
||||
if s.Replay == nil {
|
||||
display.Println(display.Yellow, "Not replaying")
|
||||
return nil
|
||||
}
|
||||
s.Replay = nil
|
||||
display.Println(display.Green, "Back to the live session")
|
||||
if s.CurrentGame != "" {
|
||||
display.Println(display.Cyan, "Current game: %s", s.CurrentGame)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showReplayPosition prints the board at the current ply with its label.
|
||||
func showReplayPosition(s *session.Session) error {
|
||||
r := s.Replay
|
||||
board, err := display.BoardFromFEN(r.FEN())
|
||||
if err != nil {
|
||||
return fmt.Errorf("stored position cannot be shown: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
display.RenderBoard(board)
|
||||
label := r.Label(r.Ply)
|
||||
if r.Ply == 0 {
|
||||
label = "initial position"
|
||||
}
|
||||
fmt.Printf("\nPly %d/%d %s\n", r.Ply, r.Len(), display.C(display.Cyan, label))
|
||||
if r.AtEnd() && r.History.Result != "" {
|
||||
display.Println(display.Green, "Result: %s", r.Result())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replayPrompt is the prompt segment while replaying.
|
||||
func ReplayPrompt(s *session.Session) string {
|
||||
if s.Replay == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" replay %d/%d", s.Replay.Ply, s.Replay.Len())
|
||||
}
|
||||
|
||||
// pgnSaveArgs splits "pgn save <path> [gameId] [ply]" style arguments:
|
||||
// a "save" followed by a path, and the remaining arguments.
|
||||
func pgnSaveArgs(args []string) (path string, rest []string, err error) {
|
||||
for i, arg := range args {
|
||||
if arg == "save" {
|
||||
if i+1 >= len(args) {
|
||||
return "", nil, fmt.Errorf("usage: pgn save <path>")
|
||||
}
|
||||
rest = append(append([]string{}, args[:i]...), args[i+2:]...)
|
||||
return args[i+1], rest, nil
|
||||
}
|
||||
}
|
||||
return "", args, nil
|
||||
}
|
||||
|
||||
func describePGNTarget(gameID string, ply int) string {
|
||||
if ply >= 0 {
|
||||
return fmt.Sprintf("%s (first %d plies)", gameID, ply)
|
||||
}
|
||||
return gameID
|
||||
}
|
||||
|
||||
func trimPGN(text string) string {
|
||||
return strings.TrimRight(text, "\n") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/client/api"
|
||||
"github.com/lixenwraith/chess/internal/client/replay"
|
||||
"github.com/lixenwraith/chess/internal/client/session"
|
||||
)
|
||||
|
||||
func TestPGNSaveArgs(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
args []string
|
||||
path string
|
||||
rest []string
|
||||
err bool
|
||||
}{
|
||||
"none": {[]string{"abc", "3"}, "", []string{"abc", "3"}, false},
|
||||
"save last": {[]string{"abc", "save", "out.pgn"}, "out.pgn", []string{"abc"}, false},
|
||||
"save first": {[]string{"save", "out.pgn", "abc", "3"}, "out.pgn", []string{"abc", "3"}, false},
|
||||
"missing path": {[]string{"save"}, "", nil, true},
|
||||
} {
|
||||
path, rest, err := pgnSaveArgs(tc.args)
|
||||
if (err != nil) != tc.err || path != tc.path || len(rest) != len(tc.rest) {
|
||||
t.Errorf("%s: path %q rest %v err %v", name, path, rest, err)
|
||||
continue
|
||||
}
|
||||
for i := range rest {
|
||||
if rest[i] != tc.rest[i] {
|
||||
t.Errorf("%s: rest = %v, want %v", name, rest, tc.rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRefusesLiveCommandsAndPrompt(t *testing.T) {
|
||||
s := &session.Session{APIBaseURL: "http://127.0.0.1:1", Client: api.New("http://127.0.0.1:1")}
|
||||
r := NewRegistry(s)
|
||||
if ReplayPrompt(s) != "" {
|
||||
t.Error("prompt segment while live")
|
||||
}
|
||||
s.Replay = replay.New(&api.GameHistoryResponse{
|
||||
GameID: "g", InitialFEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
Moves: []api.HistoryMove{{MoveNumber: 1, MoveUCI: "e2e4", SAN: "e4", FENAfterMove: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", PlayerColor: "w"}},
|
||||
})
|
||||
if got := ReplayPrompt(s); got != " replay 0/1" {
|
||||
t.Errorf("prompt segment = %q", got)
|
||||
}
|
||||
// A live command is refused without touching the client (no server).
|
||||
r.Execute("move e2e4")
|
||||
r.Execute("next")
|
||||
if s.Replay.Ply != 1 || ReplayPrompt(s) != " replay 1/1" {
|
||||
t.Errorf("after next: ply %d", s.Replay.Ply)
|
||||
}
|
||||
r.Execute("live")
|
||||
if s.Replay != nil {
|
||||
t.Error("live did not end the replay")
|
||||
}
|
||||
for _, name := range []string{"new", "join", "move", "computer", "undo", "delete", "poll", "resign", "draw"} {
|
||||
if !liveOnly[name] {
|
||||
t.Errorf("%s is not refused while replaying", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !js && !wasm
|
||||
|
||||
package command
|
||||
|
||||
import "os"
|
||||
|
||||
// savePGN writes a PGN export to path.
|
||||
func savePGN(path, text string) error {
|
||||
return os.WriteFile(path, []byte(text), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package command
|
||||
|
||||
import "fmt"
|
||||
|
||||
// savePGN has no file system in the browser: the PGN is printed instead,
|
||||
// to be copied from the terminal.
|
||||
func savePGN(path, text string) error {
|
||||
fmt.Print(text)
|
||||
return fmt.Errorf("files cannot be saved from the browser; the PGN is printed above")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BoardFromFEN renders a FEN's piece placement in the server's ASCII board
|
||||
// format (files on the first and last lines, ranks at both ends of a row,
|
||||
// "." for an empty square), so RenderBoard colors it the same way.
|
||||
func BoardFromFEN(fen string) (string, error) {
|
||||
fields := strings.Fields(fen)
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("empty FEN")
|
||||
}
|
||||
rows := strings.Split(fields[0], "/")
|
||||
if len(rows) != 8 {
|
||||
return "", fmt.Errorf("FEN has %d ranks, want 8", len(rows))
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(" a b c d e f g h\n")
|
||||
for i, row := range rows {
|
||||
rank := byte('8' - i)
|
||||
b.WriteByte(rank)
|
||||
b.WriteByte(' ')
|
||||
files := 0
|
||||
for _, c := range row {
|
||||
switch {
|
||||
case c >= '1' && c <= '8':
|
||||
for n := 0; n < int(c-'0'); n++ {
|
||||
b.WriteString(". ")
|
||||
}
|
||||
files += int(c - '0')
|
||||
case strings.ContainsRune("pnbrqkPNBRQK", c):
|
||||
b.WriteRune(c)
|
||||
b.WriteByte(' ')
|
||||
files++
|
||||
default:
|
||||
return "", fmt.Errorf("invalid FEN character %q", c)
|
||||
}
|
||||
}
|
||||
if files != 8 {
|
||||
return "", fmt.Errorf("rank %c has %d files, want 8", rank, files)
|
||||
}
|
||||
b.WriteByte(rank)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(" a b c d e f g h")
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBoardFromFEN(t *testing.T) {
|
||||
got, err := BoardFromFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := " a b c d e f g h\n" +
|
||||
"8 r n b q k b n r 8\n" +
|
||||
"7 p p p p p p p p 7\n" +
|
||||
"6 . . . . . . . . 6\n" +
|
||||
"5 . . . . . . . . 5\n" +
|
||||
"4 . . . . P . . . 4\n" +
|
||||
"3 . . . . . . . . 3\n" +
|
||||
"2 P P P P . P P P 2\n" +
|
||||
"1 R N B Q K B N R 1\n" +
|
||||
" a b c d e f g h"
|
||||
if got != want {
|
||||
t.Errorf("board:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
for _, bad := range []string{"", "8/8/8/8/8/8/8 w - - 0 1", "9/8/8/8/8/8/8/8 w - - 0 1", "x7/8/8/8/8/8/8/8 w - - 0 1"} {
|
||||
if _, err := BoardFromFEN(bad); err == nil {
|
||||
t.Errorf("BoardFromFEN(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"github.com/lixenwraith/chess/internal/client/api"
|
||||
"github.com/lixenwraith/chess/internal/client/replay"
|
||||
)
|
||||
|
||||
// Session maintains client state and configuration
|
||||
@@ -12,9 +13,13 @@ type Session struct {
|
||||
AuthToken string
|
||||
Username string
|
||||
LastMoveCount int
|
||||
GamesCursor string // next page of the "games" listing
|
||||
GamesCursor string // next page of the "games" listing
|
||||
GamesPage []api.GameSummary // the last "games" page, for "replay <index>"
|
||||
Client *api.Client
|
||||
Verbose bool
|
||||
// Replay is the stored game being stepped through, nil while live. The
|
||||
// live game, user, and poll state above are untouched by a replay.
|
||||
Replay *replay.State
|
||||
Verbose bool
|
||||
// Game state for prompt
|
||||
CurrentGameState *api.GameResponse
|
||||
PlayerColor string // "w", "b", or ""
|
||||
|
||||
Reference in new issue
Block a user