v0.14.0 web replay UI, My games, CLI replay
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 16:03:11 -04:00
1 parent 87dcb3e550
commit 35ed475904
35 files changed
+4049 -1800

No files matched your search

+31 -7
View File
@@ -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
+18
View File
@@ -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)
+289
View File
@@ -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"
}
+64
View File
@@ -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)
}
}
}
+10
View File
@@ -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)
}
+12
View File
@@ -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")
}