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 18:07:41 -04:00
1 parent 87dcb3e550
commit d48a28fad6
37 files changed
+4058 -1803

No files matched your search

+3
View File
@@ -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"`
+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")
}
+50
View File
@@ -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
}
+28
View File
@@ -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)
}
}
}
+175
View File
@@ -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
}
+91
View File
@@ -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")
}
}
+7 -2
View File
@@ -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 ""
+7 -2
View File
@@ -42,7 +42,9 @@ type UndoRequest struct {
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
// reports what a draw request did ("offered", "accepted", "declined") and is
// set only on responses to that request. Concession is the game's first
// resignation or agreed draw, kept after an undo continues play.
// resignation or agreed draw, kept after an undo continues play. SAN runs
// parallel to Moves, derived by the rules core from the position before each
// move; an entry is empty only for a move the core cannot notate.
type GameResponse struct {
GameID string `json:"gameId"`
FEN string `json:"fen"`
@@ -53,6 +55,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"`
}
@@ -113,13 +116,15 @@ type HistoryMove struct {
// GameSummary is intentionally sufficient for a client-side game picker; the
// full move list remains on the per-game history endpoint. FinalFEN is the
// position after the last stored move (the initial FEN when there is none).
// position after the last stored move (the initial FEN when there is none);
// Termination says how a finished game ended, as in the history.
type GameSummary struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
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"`
+7 -3
View File
@@ -93,10 +93,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
TimeZone: "UTC",
}))
}
// ETag and Content-Disposition are not CORS-safelisted: a browser client
// on another origin (the embedded web server's port, in development) can
// only revalidate histories and name PGN downloads when they are exposed.
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin,Content-Type,Accept,Authorization,If-None-Match",
ExposeHeaders: "ETag,Content-Disposition",
}))
// Health check (no rate limit)
+22
View File
@@ -674,6 +674,7 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
State: g.State.String(),
Termination: string(g.Termination),
Moves: g.Moves,
SAN: notateLine(g.FENs, g.Moves),
Players: core.PlayersResponse{
White: g.WhitePlayer,
Black: g.BlackPlayer,
@@ -721,6 +722,27 @@ func (p *Processor) Close() error {
return errors.Join(queueErr, engineErr)
}
// notateLine derives SAN for each live move from the position before it,
// fens[i] being the position before moves[i]. Each ply stands alone, so a
// position the core cannot parse leaves only that entry empty (and is logged
// once, by the shadow check, when the move was made).
func notateLine(fens, moves []string) []string {
san := make([]string, len(moves))
for i, uci := range moves {
if i >= len(fens) {
break
}
pos, err := chess.ParseFEN(fens[i])
if err != nil {
continue
}
if m, err := pos.ParseUCI(uci); err == nil {
san[i] = pos.SAN(m)
}
}
return san
}
// rulesResult is the rules core's verdict on a move, computed alongside the
// engine's. Stockfish stays authoritative; a disagreement is logged so the
// core can be trusted, or fixed, before it validates anything.
+29
View File
@@ -2,6 +2,8 @@ package processor
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/lixenwraith/chess/internal/server/chess"
@@ -25,6 +27,33 @@ func TestRulesCheck(t *testing.T) {
}
}
func TestNotateLine(t *testing.T) {
line, err := chess.Replay(chess.StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"})
if err != nil {
t.Fatal(err)
}
fens := make([]string, 6)
for i := range fens {
fens[i] = line.Position(i).FEN()
}
moves := []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5"}
if got := strings.Join(notateLine(fens, moves), " "); got != "e4 e5 Nf3 Nc6 Bb5" {
t.Errorf("SAN = %q", got)
}
// A ply the core cannot notate leaves only its own entry empty.
broken := append([]string(nil), fens...)
broken[2] = "not a fen"
if got := notateLine(broken, moves); !reflect.DeepEqual(got, []string{"e4", "e5", "", "Nc6", "Bb5"}) {
t.Errorf("SAN with a broken position = %q", got)
}
if got := notateLine(nil, nil); len(got) != 0 {
t.Errorf("SAN of no moves = %q", got)
}
if got := notateLine(fens[:1], moves[:2]); !reflect.DeepEqual(got, []string{"e4", ""}) {
t.Errorf("SAN short of positions = %q", got)
}
}
func TestAdjudicateDraw(t *testing.T) {
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
+1
View File
@@ -172,6 +172,7 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game
InitialFEN: record.InitialFEN,
Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
Termination: record.Termination,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
+2 -1
View File
@@ -142,7 +142,8 @@ func TestUserGamesCursorAndFilters(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" {
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" ||
page.Games[0].Termination != "checkmate" {
t.Fatalf("finished games = %+v", page.Games)
}
for _, bad := range []UserGamesOptions{
File diff suppressed because it is too large. Load diff
@@ -3,86 +3,194 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Game</title>
<meta name="color-scheme" content="dark">
<title>Chess</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="outer-container">
<div class="container">
<main>
<div class="game-area">
<div class="board-container">
<div class="board-wrapper">
<div class="coordinates top">
<span>a</span><span>b</span><span>c</span><span>d</span>
<span>e</span><span>f</span><span>g</span><span>h</span>
</div>
<div class="coordinates left">
<span>8</span><span>7</span><span>6</span><span>5</span>
<span>4</span><span>3</span><span>2</span><span>1</span>
</div>
<div id="board"></div>
</div>
<body data-mode="live">
<div class="app">
<header class="topbar">
<div class="brand" aria-label="Chess"><span class="brand-mark" aria-hidden="true">♞</span><span class="brand-name">Chess</span></div>
<div class="health" aria-label="Server status">
<span class="dot" id="server-dot" data-status="unknown" title="Server: unknown" role="img" aria-label="Server: unknown"></span>
<span class="dot" id="storage-dot" data-status="unknown" title="Storage: unknown" role="img" aria-label="Storage: unknown"></span>
</div>
<div class="topbar-spacer"></div>
<button id="games-btn" class="btn btn-ghost btn-sm" type="button" title="Stored games">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>My games</span>
</button>
<button id="auth-btn" class="btn btn-ghost btn-sm" type="button" data-status="anonymous" title="Sign in or register">
<span class="dot" id="auth-dot" data-status="anonymous" aria-hidden="true"></span>
<span id="auth-label">Sign in</span>
</button>
</header>
<main class="workspace">
<section class="board-area" aria-label="Board">
<div class="board-wrapper">
<div class="coordinates files" aria-hidden="true"></div>
<div class="coordinates ranks" aria-hidden="true"></div>
<div id="board" role="grid" aria-label="Chess board"></div>
</div>
<div class="toast-host"><div id="toast" class="toast" role="status" aria-live="polite"></div></div>
</section>
<aside class="panel" aria-label="Game panel">
<!-- Live game -->
<div class="pane pane-live" id="pane-live">
<div class="turn-strip" id="turn-strip">
<span class="dot turn-dot" id="turn-dot" data-status="idle" aria-hidden="true"></span>
<span class="turn-text" id="turn-text">No game</span>
</div>
<div class="controls" role="group" aria-label="Game controls">
<button id="new-game-btn" class="btn btn-primary" type="button">New game</button>
<button id="undo-btn" class="btn btn-secondary" type="button" disabled>Undo</button>
<button id="draw-btn" class="btn btn-secondary" type="button" title="Offer a draw" disabled>Draw</button>
<button id="resign-btn" class="btn btn-secondary" type="button" title="Resign this game" disabled>Resign</button>
</div>
<div class="moves" id="live-moves-box">
<div class="move-grid" id="live-moves" aria-label="Moves"></div>
<p class="moves-empty" id="live-moves-empty">Start a game to play Stockfish, or open <strong>My games</strong> to replay one.</p>
</div>
<div class="toolbar" role="group" aria-label="Export">
<button id="copy-fen-btn" class="btn btn-tool" type="button" title="Copy the current position as FEN" disabled>
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>FEN</span>
</button>
<button id="copy-pgn-btn" class="btn btn-tool" type="button" title="Copy the game as PGN" disabled>
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>PGN</span>
</button>
<button id="replay-live-btn" class="btn btn-tool" type="button" title="Replay this game from the start" disabled>
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<span>Replay</span>
</button>
</div>
</div>
<aside class="info-panel">
<div class="status-indicators">
<div class="indicator" id="server-indicator" data-tooltip="Server">
<span class="light" data-status="unknown">●</span>
</div>
<div class="indicator" id="storage-indicator" data-tooltip="Storage">
<span class="light" data-status="unknown">●</span>
</div>
<div class="indicator" id="turn-indicator" data-tooltip="Turn">
<span class="light turn-light" data-status="white">●</span>
</div>
<div class="indicator auth-indicator" id="auth-indicator" data-tooltip="Auth">
<span class="light" data-status="anonymous">●</span>
</div>
<div class="error-flash-overlay" id="error-flash-overlay">
<div class="error-flash-message" id="error-flash-message"></div>
<!-- Replay -->
<div class="pane pane-replay" id="pane-replay">
<div class="replay-head">
<div class="replay-title-row">
<span class="badge" id="replay-badge">Replay</span>
<span class="replay-result" id="replay-result"></span>
<span class="replay-ply" id="replay-ply" aria-hidden="true"></span>
</div>
<div class="replay-players" id="replay-players"></div>
<div class="replay-meta" id="replay-meta"></div>
</div>
<div class="controls">
<button id="new-game-btn" class="btn btn-primary">New</button>
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
<div class="moves" id="replay-moves-box">
<div class="move-grid move-grid-replay" id="replay-moves" aria-label="Moves"></div>
<p class="moves-empty" id="replay-moves-empty">Loading…</p>
</div>
<div class="move-history-container">
<button class="copy-btn" id="copy-history" title="Copy PGN">
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="9" y="9" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<div class="replay-nav" role="group" aria-label="Replay controls">
<button id="replay-first" class="btn btn-nav" type="button" title="First position (Home)" aria-label="First position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5v14M18 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
<button id="replay-prev" class="btn btn-nav" type="button" title="Previous move (←)" aria-label="Previous move">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 5l-9 7 9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
<button id="replay-play" class="btn btn-nav btn-play" type="button" title="Autoplay (Space)" aria-label="Autoplay" aria-pressed="false">
<svg class="icon icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<svg class="icon icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
</button>
<button id="replay-next" class="btn btn-nav" type="button" title="Next move (→)" aria-label="Next move">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
<button id="replay-last" class="btn btn-nav" type="button" title="Last position (End)" aria-label="Last position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 5v14M6 5l9 7-9 7z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
<label class="speed" title="Autoplay speed">
<span class="visually-hidden">Autoplay speed</span>
<select id="replay-speed">
<option value="2000">0.5×</option>
<option value="1000" selected>1×</option>
<option value="500">2×</option>
<option value="250">4×</option>
</select>
</label>
<button id="replay-flip" class="btn btn-nav" type="button" title="Flip the board (F)" aria-label="Flip the board" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v12M7 16l-3-3M7 16l3-3M17 20V8M17 8l-3 3M17 8l3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div id="move-history" class="move-history">
<div class="move-grid" id="move-grid"></div>
</div>
</div>
<div class="toolbar" role="group" aria-label="Export at this position">
<button id="replay-copy-fen" class="btn btn-tool" type="button" title="Copy the FEN of this position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>FEN</span>
</button>
<button id="replay-copy-pgn" class="btn btn-tool" type="button" title="Copy the PGN up to this position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>PGN</span>
</button>
<button id="replay-download" class="btn btn-tool" type="button" title="Download the PGN up to this position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 19h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Save</span>
</button>
<button id="replay-link" class="btn btn-tool" type="button" title="Copy a link to this position">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Link</span>
</button>
</div>
<button id="replay-exit" class="btn btn-secondary btn-block" type="button">Back to live game</button>
<div id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
</div>
</aside>
</main>
</div>
</aside>
</main>
<!-- My games drawer -->
<div id="games-overlay" class="modal-overlay">
<div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title">
<div class="modal-head">
<h2 id="games-title">My games</h2>
<button id="games-close" class="btn btn-icon" type="button" aria-label="Close">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
<div class="games-filters" id="games-filters">
<div class="segmented" role="group" aria-label="Status">
<button type="button" class="seg active" data-filter="status" data-value="">All</button>
<button type="button" class="seg" data-filter="status" data-value="ongoing">Ongoing</button>
<button type="button" class="seg" data-filter="status" data-value="finished">Finished</button>
</div>
<div class="segmented" role="group" aria-label="Your colour">
<button type="button" class="seg active" data-filter="color" data-value="">Any</button>
<button type="button" class="seg" data-filter="color" data-value="white">White</button>
<button type="button" class="seg" data-filter="color" data-value="black">Black</button>
</div>
</div>
<div class="games-body">
<section class="games-section" id="account-games-section">
<h3 class="games-heading" id="account-games-heading">Your games</h3>
<p class="games-note" id="games-signin-note">Sign in to keep your games and replay them here. Games played anonymously are kept for 24 hours.</p>
<ul class="games-list" id="games-list"></ul>
<p class="games-status" id="games-status" role="status"></p>
<button id="games-more" class="btn btn-secondary btn-block" type="button">Load more</button>
</section>
<section class="games-section" id="local-games-section">
<h3 class="games-heading">On this device</h3>
<p class="games-note">Games played here without signing in; the server keeps them for 24 hours after the last move.</p>
<ul class="games-list" id="local-games-list"></ul>
</section>
</div>
</div>
</div>
<!-- Auth Modal -->
<div id="auth-modal-overlay" class="modal-overlay">
<div class="modal auth-modal">
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<h2 id="auth-title" class="visually-hidden">Account</h2>
<div class="auth-tabs" role="tablist">
<button class="auth-tab active" type="button" role="tab" data-tab="login" aria-selected="true">Sign in</button>
<button class="auth-tab" type="button" role="tab" data-tab="register" aria-selected="false">Register</button>
</div>
<div id="auth-modal-message" class="modal-message"></div>
<div id="auth-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<!-- Login Form -->
<div id="login-form" class="auth-form">
<div class="form-group">
<label for="login-identifier">Username or Email</label>
<label for="login-identifier">Username or email</label>
<input type="text" id="login-identifier" autocomplete="username">
</div>
<div class="form-group">
@@ -90,13 +198,12 @@
<input type="password" id="login-password" autocomplete="current-password">
</div>
<div class="modal-buttons">
<button id="login-submit-btn" class="btn btn-primary">Login</button>
<button id="auth-cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
<!-- Register Form -->
<div id="register-form" class="auth-form" style="display: none;">
<div id="register-form" class="auth-form hidden">
<div class="form-group">
<label for="register-username">Username</label>
<input type="text" id="register-username" autocomplete="username">
@@ -106,12 +213,12 @@
<input type="email" id="register-email" autocomplete="email">
</div>
<div class="form-group">
<label for="register-password">Password (min 8 chars)</label>
<label for="register-password">Password (8+ characters, a letter and a number)</label>
<input type="password" id="register-password" autocomplete="new-password">
</div>
<div class="modal-buttons">
<button id="register-submit-btn" class="btn btn-primary">Register</button>
<button id="auth-cancel-btn-2" class="btn btn-secondary">Cancel</button>
<button id="register-submit-btn" class="btn btn-primary" type="button">Register</button>
<button id="auth-cancel-btn-2" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
@@ -124,8 +231,8 @@
<div id="account-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<p class="account-name">Signed in as <strong id="account-username"></strong></p>
<div class="modal-buttons">
<button id="logout-btn" class="btn btn-primary">Log out</button>
<button id="account-cancel-btn" class="btn btn-secondary">Close</button>
<button id="logout-btn" class="btn btn-primary" type="button">Sign out</button>
<button id="account-cancel-btn" class="btn btn-secondary" type="button">Close</button>
</div>
<details class="danger-zone" id="delete-account">
<summary>Delete account</summary>
@@ -137,40 +244,40 @@
<input type="password" id="delete-password" autocomplete="current-password">
</div>
<div class="modal-buttons">
<button id="delete-account-btn" class="btn btn-danger">Delete account</button>
<button id="delete-account-btn" class="btn btn-danger" type="button">Delete account</button>
</div>
</details>
</div>
</div>
<!-- Overlay Modal -->
<!-- New Game Modal -->
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<h2>New Game</h2>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="new-game-title">
<h2 id="new-game-title">New game</h2>
<div id="new-game-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="form-group form-row">
<span class="group-label" id="player-color-label">Your Color</span>
<span class="group-label" id="player-color-label">Your colour</span>
<div class="radio-group" role="radiogroup" aria-labelledby="player-color-label">
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
</div>
</div>
<div class="form-group">
<label for="computer-level">Computer Level: <span id="level-value">10</span></label>
<label for="computer-level">Computer level: <span id="level-value">10</span></label>
<input type="range" id="computer-level" min="0" max="20" value="10">
</div>
<div class="form-group">
<label for="search-time">Search Time (ms): <span id="time-value">1000</span></label>
<label for="search-time">Search time: <span id="time-value">1000</span> ms</label>
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
</div>
<div class="form-group">
<label for="starting-fen">Starting Position (FEN)</label>
<textarea id="starting-fen" class="fen-input" rows="2"
<label for="starting-fen">Starting position (FEN)</label>
<textarea id="starting-fen" class="fen-input" rows="2" spellcheck="false"
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
</div>
<div class="modal-buttons">
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="start-game-btn" class="btn btn-primary" type="button">Start game</button>
<button id="cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
@@ -180,8 +287,8 @@
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
<h2 id="resign-title">Resign this game?</h2>
<div class="modal-buttons">
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="resign-confirm-btn" class="btn btn-primary" type="button">Resign</button>
<button id="resign-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
@@ -191,13 +298,13 @@
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
<h2 id="promotion-title">Promote to</h2>
<div class="promotion-choices">
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
<button class="promotion-choice" type="button" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
<button class="promotion-choice" type="button" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
<button class="promotion-choice" type="button" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
<button class="promotion-choice" type="button" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
</div>
<div class="modal-buttons">
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="promotion-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff