From 8ba43579201aa46a63db5dab26aa74b251dc67089b18fb2ea4f3261d09602912 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sun, 26 Oct 2025 17:34:50 -0400 Subject: [PATCH] v0.1.0 chess game in go, using external stockfish engine --- .gitignore | 5 + LICENSE | 28 +++ README.md | 48 +++++ cmd/chess/main.go | 26 +++ cmd/chessd/main.go | 24 +++ go.mod | 5 + go.sum | 2 + internal/board/board.go | 88 +++++++++ internal/cli/cli.go | 293 ++++++++++++++++++++++++++++++ internal/core/core.go | 53 ++++++ internal/engine/engine.go | 231 +++++++++++++++++++++++ internal/game/game.go | 121 ++++++++++++ internal/service/service.go | 232 +++++++++++++++++++++++ internal/transport/cli/handler.go | 248 +++++++++++++++++++++++++ internal/transport/transport.go | 29 +++ 15 files changed, 1433 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/chess/main.go create mode 100644 cmd/chessd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/board/board.go create mode 100644 internal/cli/cli.go create mode 100644 internal/core/core.go create mode 100644 internal/engine/engine.go create mode 100644 internal/game/game.go create mode 100644 internal/service/service.go create mode 100644 internal/transport/cli/handler.go create mode 100644 internal/transport/transport.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a43c98f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +dev +log +bin +build.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c71f04c --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, Lixen Wraith + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7ca9fa --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ + + + + +
+

Go Chess

+

+ Go + License +

+
+ +# Go Chess + +A command-line chess application written in Go. + +## Features + +* Command-line interface for gameplay. +* Uses an stockfish external chess engine for move validation and computer play. +* Supports player vs. player, player vs. computer, and computer vs. computer modes. +* Start a new game from the standard starting position. +* Resume a game from a FEN (Forsyth-Edwards Notation) string. +* Move history display. +* Move undo functionality. + +## System Requirements + +* **Go Version**: 1.24+ (for building from source) +* **Engine**: Requires the **Stockfish** chess engine to be installed. The `stockfish` executable must be available in the system's PATH. + +## Quick Start + +To build and run the application: + +```sh +# Build the executable +go build ./cmd/chess + +# Run the application +./chess +``` + +Inside the application, type `help` to see available commands. + +## License + +BSD 3-Clause License diff --git a/cmd/chess/main.go b/cmd/chess/main.go new file mode 100644 index 0000000..595f2ab --- /dev/null +++ b/cmd/chess/main.go @@ -0,0 +1,26 @@ +// FILE: cmd/chess/main.go +package main + +import ( + "fmt" + "os" + + "chess/internal/cli" + "chess/internal/service" + clitransport "chess/internal/transport/cli" +) + +func main() { + svc, err := service.New() + if err != nil { + fmt.Printf("Failed to start: %v\n", err) + os.Exit(1) + } + defer svc.Close() + + view := cli.New(os.Stdin, os.Stdout) + handler := clitransport.New(svc, view) + + view.ShowWelcome() + handler.Run() // All game loop logic is in the handler +} diff --git a/cmd/chessd/main.go b/cmd/chessd/main.go new file mode 100644 index 0000000..0fe9bd5 --- /dev/null +++ b/cmd/chessd/main.go @@ -0,0 +1,24 @@ +// FILE: cmd/chessd/main.go +package main + +import ( + "fmt" + "log" + "os" + + "chess/internal/service" +) + +func main() { + // Placeholder for future API server implementation + svc, err := service.New() + if err != nil { + log.Fatalf("Failed to initialize service: %v", err) + } + defer svc.Close() + + // TODO: Phase 2 - Add HTTP/WebSocket server here + fmt.Println("Chess server daemon - not yet implemented") + fmt.Println("This will host the API in Phase 2") + os.Exit(0) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..883da4c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module chess + +go 1.24 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a1e72c7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/board/board.go b/internal/board/board.go new file mode 100644 index 0000000..c476e7d --- /dev/null +++ b/internal/board/board.go @@ -0,0 +1,88 @@ +// FILE: internal/board/board.go +package board + +import ( + "fmt" + "strings" + + "chess/internal/core" +) + +const ( + StartingFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" +) + +type Board struct { + squares [8][8]byte + turn core.Color + castling string + enPassant string + halfmove int + fullmove int +} + +func FEN(fen string) (*Board, error) { + parts := strings.Fields(fen) + if len(parts) != 6 { + return nil, fmt.Errorf("invalid FEN: expected 6 parts, got %d", len(parts)) + } + + b := &Board{} + + // Parse board + ranks := strings.Split(parts[0], "/") + if len(ranks) != 8 { + return nil, fmt.Errorf("invalid FEN: expected 8 ranks") + } + + for r := 0; r < 8; r++ { + file := 0 + for _, ch := range ranks[r] { + if ch >= '1' && ch <= '8' { + file += int(ch - '0') + } else { + if file >= 8 { + return nil, fmt.Errorf("invalid FEN: too many pieces in rank %d", r+1) + } + b.squares[r][file] = byte(ch) + file++ + } + } + if file != 8 { + return nil, fmt.Errorf("invalid FEN: rank %d has %d files", r+1, file) + } + } + + // Parse game state with validation + if len(parts[1]) != 1 || (parts[1][0] != 'w' && parts[1][0] != 'b') { + return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'") + } + b.turn = core.Color(parts[1][0]) + b.castling = parts[2] + b.enPassant = parts[3] + + if _, err := fmt.Sscanf(parts[4], "%d", &b.halfmove); err != nil { + return nil, fmt.Errorf("invalid FEN: halfmove counter") + } + if _, err := fmt.Sscanf(parts[5], "%d", &b.fullmove); err != nil { + return nil, fmt.Errorf("invalid FEN: fullmove counter") + } + + return b, nil +} + +func (b *Board) Turn() core.Color { + return b.turn +} + +func (b *Board) GetPieceAt(square string) byte { + if len(square) != 2 { + return 0 + } + if square[0] < 'a' || square[0] > 'h' || square[1] < '1' || square[1] > '8' { + return 0 + } + file := square[0] - 'a' + rank := '8' - square[1] + return b.squares[rank][file] +} \ No newline at end of file diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..dfc27da --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,293 @@ +// FILE: internal/cli/cli.go +package cli + +import ( + "bufio" + "fmt" + "io" + "strings" + + "chess/internal/board" + "chess/internal/core" + "chess/internal/game" +) + +type CommandType int + +const ( + CmdNone CommandType = iota + CmdNew + CmdResume + CmdMove + CmdUndo + CmdColor + CmdVerbose + CmdHistory + CmdHelp + CmdQuit +) + +type Command struct { + Type CommandType + Args []string + Raw string +} + +type ColorTheme string + +const ( + ThemeOff ColorTheme = "off" + ThemeBrown ColorTheme = "brown" + ThemeGreen ColorTheme = "green" + ThemeGray ColorTheme = "gray" +) + +type themeColors struct { + lightBg string + darkBg string + white string + black string + reset string +} + +var themes = map[ColorTheme]themeColors{ + ThemeOff: { + lightBg: "", + darkBg: "", + white: "", + black: "", + reset: "", + }, + ThemeBrown: { + lightBg: "\033[48;5;230m", // Beige + darkBg: "\033[48;5;94m", // Brown + white: "\033[97m", + black: "\033[30m", + reset: "\033[0m", + }, + ThemeGreen: { + lightBg: "\033[48;5;157m", // Light green + darkBg: "\033[48;5;22m", // Dark green + white: "\033[97m", + black: "\033[30m", + reset: "\033[0m", + }, + ThemeGray: { + lightBg: "\033[48;5;251m", // Light gray + darkBg: "\033[48;5;240m", // Dark gray + white: "\033[97m", + black: "\033[30m", + reset: "\033[0m", + }, +} + +type CLI struct { + input *bufio.Scanner + output io.Writer + theme ColorTheme + verbose bool +} + +func New(input io.Reader, output io.Writer) *CLI { + return &CLI{ + input: bufio.NewScanner(input), + output: output, + theme: ThemeOff, + verbose: false, + } +} + +// Reads a command synchronously +func (c *CLI) GetCommand() (*Command, error) { + if !c.input.Scan() { + if err := c.input.Err(); err != nil { + return nil, err + } + return &Command{Type: CmdQuit}, nil + } + + input := strings.TrimSpace(c.input.Text()) + if input == "" { + return &Command{Type: CmdNone}, nil + } + + return c.parseCommand(input), nil +} + +func (c *CLI) parseCommand(input string) *Command { + parts := strings.Fields(input) + if len(parts) == 0 { + return &Command{Type: CmdNone} + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "new": + return &Command{Type: CmdNew, Args: args} + case "resume": + return &Command{Type: CmdResume, Args: args, Raw: input} + case "undo": + return &Command{Type: CmdUndo, Args: args} + case "color": + return &Command{Type: CmdColor, Args: args} + case "verbose": + return &Command{Type: CmdVerbose} + case "history": + return &Command{Type: CmdHistory} + case "help", "?": + return &Command{Type: CmdHelp} + case "quit", "exit": + return &Command{Type: CmdQuit} + default: + // Assume it's a move + return &Command{Type: CmdMove, Args: []string{cmd}} + } +} + +func (c *CLI) SetTheme(theme ColorTheme) error { + if _, ok := themes[theme]; !ok { + return fmt.Errorf("invalid theme: %s (use: off, brown, green, gray)", theme) + } + c.theme = theme + return nil +} + +func (c *CLI) ToggleVerbose() bool { + c.verbose = !c.verbose + return c.verbose +} + +func (c *CLI) IsVerbose() bool { + return c.verbose +} + +func (c *CLI) ShowMessage(msg string) { + fmt.Fprintln(c.output, msg) +} + +func (c *CLI) ShowError(err error) { + c.ShowMessage(fmt.Sprintf("Error: %v\n", err)) +} + +func (c *CLI) ShowPrompt(prompt string) { + fmt.Fprint(c.output, prompt) +} + +func (c *CLI) ReadLine() string { + if c.input.Scan() { + return strings.TrimSpace(c.input.Text()) + } + return "" +} + +func (c *CLI) DisplayBoard(b *board.Board) { + theme := themes[c.theme] + var sb strings.Builder + + sb.WriteString("\n a b c d e f g h\n") + + for r := 0; r < 8; r++ { + sb.WriteString(fmt.Sprintf("%d ", 8-r)) + for f := 0; f < 8; f++ { + // Get piece at position + square := fmt.Sprintf("%c%c", 'a'+f, '8'-r) + piece := b.GetPieceAt(square) + + if c.theme == ThemeOff { + // No colors, just show piece or space + if piece == 0 { + sb.WriteString(" ") + } else { + sb.WriteString(fmt.Sprintf("%c ", piece)) + } + } else { + // Apply theme colors + bg := theme.darkBg + if (r+f)%2 == 0 { + bg = theme.lightBg + } + + if piece == 0 { + sb.WriteString(fmt.Sprintf("%s %s", bg, theme.reset)) + } else { + color := theme.black + if piece >= 'A' && piece <= 'Z' { + color = theme.white + } + sb.WriteString(fmt.Sprintf("%s%s%c %s", bg, color, piece, theme.reset)) + } + } + } + sb.WriteString(fmt.Sprintf(" %d\n", 8-r)) + } + sb.WriteString(" a b c d e f g h\n") + + c.ShowMessage(sb.String()) +} + +func (c *CLI) ShowHelp() { + help := `Commands: + new - Start a new game with player type selection + resume - Resume from a specific board position + - Make a move (e.g., e2e4, g1f3) + undo [count] - Undo last move(s), default 1 + color - Set board color theme (off|brown|green|gray) + verbose - Toggle detailed move information + history - Show game move history and positions + quit/exit - Exit the program + help/? - Show this help message + +During any game: + Press ENTER - Execute computer move (when it's computer's turn)` + + c.ShowMessage(help) +} + +func (c *CLI) ShowWelcome() { + c.ShowMessage("Welcome to Chess!") + c.ShowMessage("Commands: new, resume , , undo, quit/exit, verbose, history, help/?") + c.ShowMessage("Example: 'resume 4k3/8/8/8/8/8/8/4K2R w K - 0 1' to start from a puzzle.") + c.ShowMessage("Press ENTER to execute computer moves when it's computer's turn.") + c.ShowMessage("") +} + +func (c *CLI) ShowGameHistory(g *game.Game) { + c.ShowMessage(fmt.Sprintf("Starting FEN: %s\n", g.InitialFEN())) + + moves := g.Moves() + for i := 0; i < len(moves); i += 2 { + moveNum := i/2 + 1 + white := moves[i] + if i+1 < len(moves) { + black := moves[i+1] + c.ShowMessage(fmt.Sprintf("%d. %s | %s\n", moveNum, white, black)) + } else { + c.ShowMessage(fmt.Sprintf("%d. %s | ...\n", moveNum, white)) + } + } + c.ShowMessage(fmt.Sprintf("Current FEN: %s\n", g.CurrentFEN())) + c.ShowMessage(fmt.Sprintf("Game state: %s\n", g.State())) +} + +func (c *CLI) ShowComputerMove(result *game.MoveResult) { + if c.verbose { + c.ShowMessage(fmt.Sprintf("Computer (%c): %s (depth=%d, score=%d)\n", + result.Player, result.Move, result.Depth, result.Score)) + } else { + // Always show computer moves in non-verbose mode too + c.ShowMessage(fmt.Sprintf("Computer (%c): %s\n", result.Player, result.Move)) + } +} + +func (c *CLI) ShowHumanMove(move string) { + if c.verbose { + c.ShowMessage(fmt.Sprintf("Your move: %s\n", move)) + } +} + +func (c *CLI) ShowGameOver(state core.State) { + c.ShowMessage(fmt.Sprintf("\nGame Over: %s\n", state)) + c.ShowMessage("Start a new game with 'new' or 'resume'.") +} \ No newline at end of file diff --git a/internal/core/core.go b/internal/core/core.go new file mode 100644 index 0000000..34e59ac --- /dev/null +++ b/internal/core/core.go @@ -0,0 +1,53 @@ +// FILE: internal/core/core.go +package core + +type State int + +const ( + StateOngoing State = iota + StateWhiteWins + StateBlackWins + StateDraw + StateStalemate +) + +func (s State) String() string { + switch s { + case StateWhiteWins: + return "White wins" + case StateBlackWins: + return "Black wins" + case StateDraw: + return "Draw" + case StateStalemate: + return "Stalemate" + default: + return "Ongoing" + } +} + +type PlayerType int + +const ( + PlayerHuman PlayerType = iota + PlayerComputer +) + +type Player struct { + ID string + Type PlayerType +} + +type Color byte + +const ( + ColorWhite Color = 'w' + ColorBlack Color = 'b' +) + +func OppositeColor(c Color) Color { + if c == ColorWhite { + return ColorBlack + } + return ColorWhite +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1fd45ef --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,231 @@ +// FILE: internal/engine/engine.go +package engine + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +const enginePath = "stockfish" + +type UCI struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + mu sync.Mutex +} + +type SearchResult struct { + BestMove string + Score int + Depth int +} + +func New() (*UCI, error) { + cmd := exec.Command(enginePath) + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start engine: %v", err) + } + + uci := &UCI{ + cmd: cmd, + stdin: stdin, + stdout: bufio.NewScanner(stdout), + } + + if err := uci.initialize(); err != nil { + uci.Close() + return nil, err + } + + return uci, nil +} + +// Get FEN from Stockfish's debug ('d') command +func (u *UCI) GetFEN() (string, error) { + u.sendCommand("d") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + done := make(chan string, 1) + go func() { + for u.stdout.Scan() { + line := u.stdout.Text() + if strings.HasPrefix(line, "Fen: ") { + done <- strings.TrimPrefix(line, "Fen: ") + return + } + } + done <- "" + }() + + select { + case fen := <-done: + if fen == "" { + return "", fmt.Errorf("failed to get FEN from engine") + } + return fen, nil + case <-ctx.Done(): + return "", fmt.Errorf("timeout getting FEN") + } +} + +func (u *UCI) initialize() error { + u.sendCommand("uci") + + // Wait for uciok with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan bool) + go func() { + for u.stdout.Scan() { + if u.stdout.Text() == "uciok" { + done <- true + return + } + } + done <- false + }() + + select { + case success := <-done: + if !success { + return fmt.Errorf("engine closed unexpectedly") + } + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for uciok") + } + + u.sendCommand("isready") + return u.waitReady() +} + +func (u *UCI) waitReady() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error) + go func() { + for u.stdout.Scan() { + if u.stdout.Text() == "readyok" { + done <- nil + return + } + } + done <- fmt.Errorf("engine closed unexpectedly") + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for readyok") + } +} + +func (u *UCI) sendCommand(cmd string) { + u.mu.Lock() + defer u.mu.Unlock() + fmt.Fprintln(u.stdin, cmd) +} + +func (u *UCI) NewGame() { + u.sendCommand("ucinewgame") + u.sendCommand("isready") + u.waitReady() +} + +func (u *UCI) SetPosition(fen string, moves []string) { + cmd := fmt.Sprintf("position fen %s", fen) + if len(moves) > 0 { + cmd += " moves " + strings.Join(moves, " ") + } + u.sendCommand(cmd) +} + +func (u *UCI) Search(timeMs int) (*SearchResult, error) { + u.sendCommand(fmt.Sprintf("go movetime %d", timeMs)) + + result := &SearchResult{} + + // Add timeout protection (2x the search time + buffer) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond) + defer cancel() + + done := make(chan error) + go func() { + for u.stdout.Scan() { + line := u.stdout.Text() + + if strings.HasPrefix(line, "info ") { + fields := strings.Fields(line) + for i := 0; i < len(fields)-1; i++ { + switch fields[i] { + case "depth": + fmt.Sscanf(fields[i+1], "%d", &result.Depth) + case "cp": + fmt.Sscanf(fields[i+1], "%d", &result.Score) + } + } + } + + if strings.HasPrefix(line, "bestmove ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + result.BestMove = parts[1] + } + done <- nil + return + } + } + done <- fmt.Errorf("engine closed unexpectedly") + }() + + select { + case err := <-done: + if err != nil { + return nil, err + } + return result, nil + case <-ctx.Done(): + return nil, fmt.Errorf("timeout waiting for bestmove") + } +} + +func (u *UCI) Close() error { + u.sendCommand("quit") + time.Sleep(100 * time.Millisecond) + + // Try graceful shutdown first + done := make(chan error, 1) + go func() { + done <- u.cmd.Wait() + }() + + select { + case <-done: + return nil + case <-time.After(1 * time.Second): + // Force kill if doesn't exit gracefully + return u.cmd.Process.Kill() + } +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..cedf40e --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,121 @@ +// FILE: internal/game/game.go +package game + +import ( + "fmt" + + "chess/internal/board" + "chess/internal/core" +) + +type Snapshot struct { + FEN string // Board state at this point + PreviousMove string // Move that created this position (empty for initial) + NextTurn core.Color // Whose turn it is at this position +} + +// MoveResult tracks the outcome of a move +type MoveResult struct { + Move string + Player core.Color + GameState core.State + Score int + Depth int +} + +type Game struct { + snapshots []Snapshot + players map[core.Color]*core.Player + state core.State + lastResult *MoveResult +} + +func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurn core.Color) *Game { + return &Game{ + snapshots: []Snapshot{ + { + FEN: initialFEN, + PreviousMove: "", // No move led to initial position + NextTurn: startingTurn, + }, + }, + players: map[core.Color]*core.Player{ + core.ColorWhite: whitePlayer, + core.ColorBlack: blackPlayer, + }, + state: core.StateOngoing, + } +} + +func (g *Game) SetLastResult(result *MoveResult) { + g.lastResult = result +} + +func (g *Game) LastResult() *MoveResult { + return g.lastResult +} + +func (g *Game) CurrentSnapshot() Snapshot { + return g.snapshots[len(g.snapshots)-1] +} + +func (g *Game) CurrentFEN() string { + return g.CurrentSnapshot().FEN +} + +func (g *Game) NextTurn() core.Color { + return g.CurrentSnapshot().NextTurn +} + +func (g *Game) NextPlayer() *core.Player { + return g.players[g.NextTurn()] +} + +func (g *Game) AddSnapshot(fen string, move string, nextTurn core.Color) { + g.snapshots = append(g.snapshots, Snapshot{ + FEN: fen, + PreviousMove: move, + NextTurn: nextTurn, + }) +} + +func (g *Game) UndoMoves(count int) error { + if count < 1 { + return fmt.Errorf("invalid undo count: %d", count) + } + + availableMoves := len(g.snapshots) - 1 + if availableMoves < count { + return fmt.Errorf("cannot undo %d moves: only %d moves available", count, availableMoves) + } + + g.snapshots = g.snapshots[:len(g.snapshots)-count] + g.state = core.StateOngoing // Reset game state when undoing + g.lastResult = nil // Clear last result + return nil +} + +func (g *Game) Moves() []string { + moves := []string{} + for i := 1; i < len(g.snapshots); i++ { + if g.snapshots[i].PreviousMove != "" { + moves = append(moves, g.snapshots[i].PreviousMove) + } + } + return moves +} + +func (g *Game) State() core.State { + return g.state +} + +func (g *Game) SetState(s core.State) { + g.state = s +} + +func (g *Game) InitialFEN() string { + if len(g.snapshots) > 0 { + return g.snapshots[0].FEN + } + return board.StartingFEN +} \ No newline at end of file diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..a603207 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,232 @@ +// FILE: internal/service/service.go +package service + +import ( + "fmt" + "strings" + + "chess/internal/board" + "chess/internal/core" + "chess/internal/engine" + "chess/internal/game" +) + +type Service struct { + games map[string]*game.Game + engine *engine.UCI +} + +func New() (*Service, error) { + eng, err := engine.New() + if err != nil { + return nil, fmt.Errorf("failed to initialize engine: %v", err) + } + + return &Service{ + games: make(map[string]*game.Game), + engine: eng, + }, nil +} + +func (s *Service) NewGame(id string, whiteType, blackType core.PlayerType, fen ...string) error { + initialFEN := board.StartingFEN + if len(fen) > 0 && fen[0] != "" { + initialFEN = fen[0] + } + + // Use the engine to validate and canonicalize the FEN + s.engine.NewGame() + s.engine.SetPosition(initialFEN, []string{}) + validatedFEN, err := s.engine.GetFEN() + if err != nil { + return fmt.Errorf("could not get FEN from engine: %v", err) + } + + b, err := board.FEN(validatedFEN) + if err != nil { + return fmt.Errorf("engine returned invalid FEN: %v", err) + } + startingTurn := b.Turn() + + // Setup players based on types + whitePlayer := &core.Player{ + ID: "white", + Type: whiteType, + } + if whiteType == core.PlayerComputer { + whitePlayer.ID = "stockfish-white" + } + + blackPlayer := &core.Player{ + ID: "black", + Type: blackType, + } + if blackType == core.PlayerComputer { + blackPlayer.ID = "stockfish-black" + } + + s.games[id] = game.New(validatedFEN, whitePlayer, blackPlayer, startingTurn) + + return nil +} + +func (s *Service) MakeHumanMove(gameID, uci string) error { + // Basic move format validation + uci = strings.ToLower(strings.TrimSpace(uci)) + if len(uci) < 4 || len(uci) > 5 { + return fmt.Errorf("invalid move format: expected e2e4 or e7e8q") + } + + g, ok := s.games[gameID] + if !ok { + return fmt.Errorf("game not found") + } + + // Check if it's human's turn + if g.NextPlayer().Type != core.PlayerHuman { + return fmt.Errorf("not a human player's turn") + } + + currentFEN := g.CurrentFEN() + humanColor := g.NextTurn() + + // Try to apply human move + s.engine.SetPosition(currentFEN, []string{uci}) + + // Get FEN after human move to check if move was legal + humanMoveFEN, err := s.engine.GetFEN() + if err != nil { + return fmt.Errorf("failed to get position: %v", err) + } + + // If position didn't change, move was illegal + if humanMoveFEN == currentFEN { + return fmt.Errorf("illegal move") + } + + // Record human move + g.AddSnapshot(humanMoveFEN, uci, core.OppositeColor(humanColor)) + + // Check if opponent has any legal moves + s.engine.SetPosition(humanMoveFEN, []string{}) + search, _ := s.engine.Search(100) // Quick search to check for legal moves + + result := &game.MoveResult{ + Move: uci, + Player: humanColor, + GameState: core.StateOngoing, + } + + if search.BestMove == "" || search.BestMove == "(none)" { + // Human checkmated the opponent + if humanColor == core.ColorWhite { + g.SetState(core.StateWhiteWins) + } else { + g.SetState(core.StateBlackWins) + } + result.GameState = g.State() + } + + // Store result in game instead of service + g.SetLastResult(result) + return nil +} + +func (s *Service) MakeComputerMove(gameID string) (*game.MoveResult, error) { + g, ok := s.games[gameID] + if !ok { + return nil, fmt.Errorf("game not found: %s", gameID) + } + + if g.NextPlayer().Type != core.PlayerComputer { + return nil, fmt.Errorf("not computer's turn") + } + + currentColor := g.NextTurn() + s.engine.SetPosition(g.CurrentFEN(), []string{}) + search, err := s.engine.Search(1000) + if err != nil { + return nil, fmt.Errorf("engine error: %v", err) + } + + result := &game.MoveResult{ + Player: currentColor, + Score: search.Score, + Depth: search.Depth, + GameState: core.StateOngoing, + } + + if search.BestMove == "" || search.BestMove == "(none)" { + // No legal moves - computer is checkmated + if currentColor == core.ColorWhite { + g.SetState(core.StateBlackWins) + } else { + g.SetState(core.StateWhiteWins) + } + result.GameState = g.State() + g.SetLastResult(result) + return result, nil + } + + result.Move = search.BestMove + + // Apply move and get resulting FEN + s.engine.SetPosition(g.CurrentFEN(), []string{search.BestMove}) + newFEN, err := s.engine.GetFEN() + if err != nil { + return nil, fmt.Errorf("failed to get position: %v", err) + } + + g.AddSnapshot(newFEN, search.BestMove, core.OppositeColor(currentColor)) + + // Check if opponent has any legal moves + s.engine.SetPosition(newFEN, []string{}) + testSearch, _ := s.engine.Search(100) + + if testSearch.BestMove == "" || testSearch.BestMove == "(none)" { + // Computer checkmated the opponent + if currentColor == core.ColorWhite { + g.SetState(core.StateWhiteWins) + } else { + g.SetState(core.StateBlackWins) + } + result.GameState = g.State() + } + + // Store result in game + g.SetLastResult(result) + return result, nil +} + +func (s *Service) Undo(gameID string, count int) error { + g, ok := s.games[gameID] + if !ok { + return fmt.Errorf("game not found: %s", gameID) + } + + return g.UndoMoves(count) +} + +func (s *Service) GetCurrentBoard(gameID string) (*board.Board, error) { + g, ok := s.games[gameID] + if !ok { + return nil, fmt.Errorf("game not found: %s", gameID) + } + + return board.FEN(g.CurrentFEN()) +} + +func (s *Service) GetGame(gameID string) (*game.Game, error) { + g, ok := s.games[gameID] + if !ok { + return nil, fmt.Errorf("game not found: %s", gameID) + } + return g, nil +} + +func (s *Service) Close() error { + if s.engine != nil { + return s.engine.Close() + } + return nil +} \ No newline at end of file diff --git a/internal/transport/cli/handler.go b/internal/transport/cli/handler.go new file mode 100644 index 0000000..52f3881 --- /dev/null +++ b/internal/transport/cli/handler.go @@ -0,0 +1,248 @@ +// FILE: internal/transport/cli/handler.go +package cli + +import ( + "fmt" + "strconv" + "strings" + + "chess/internal/cli" + "chess/internal/core" + "chess/internal/service" + + "github.com/google/uuid" +) + +type CLIHandler struct { + svc *service.Service + view *cli.CLI + gameID string +} + +func New(svc *service.Service, view *cli.CLI) *CLIHandler { + return &CLIHandler{ + svc: svc, + view: view, + } +} + +// Main game loop - simple command processing +func (h *CLIHandler) Run() { + for { + // Generate prompt based on current game state + prompt := h.getPrompt() + h.view.ShowPrompt(prompt) + + // Get command (blocking) + cmd, err := h.view.GetCommand() + if err != nil { + break + } + + // Process command - returns false to exit + if !h.ProcessCommand(cmd) { + break + } + } +} + +// Generates the appropriate command prompt +func (h *CLIHandler) getPrompt() string { + prompt := "> " + if h.gameID != "" { + g, err := h.svc.GetGame(h.gameID) + if err == nil && g.State() == core.StateOngoing { + // Always show whose turn it is + prompt = fmt.Sprintf("[%c]> ", g.NextTurn()) + if g.NextPlayer().Type == core.PlayerComputer { + prompt = "ENTER to execute computer move\n" + prompt + } + } + } + return prompt +} + +// Handles user commands - returns false to exit +func (h *CLIHandler) ProcessCommand(cmd *cli.Command) bool { + switch cmd.Type { + case cli.CmdQuit: + return false + + case cli.CmdNone: + // Empty command triggers computer move if it's computer's turn + if h.gameID != "" { + g, err := h.svc.GetGame(h.gameID) + if err == nil && g.State() == core.StateOngoing && + g.NextPlayer().Type == core.PlayerComputer { + h.executeComputerMove() + } + } + return true + + case cli.CmdNew: + return h.handleNewGame("") + + case cli.CmdResume: + if len(cmd.Args) < 1 { + h.view.ShowMessage("Usage: resume ") + return true + } + fen := strings.Join(cmd.Args, " ") + return h.handleNewGame(fen) + + case cli.CmdMove: + if h.gameID == "" { + h.view.ShowMessage("No active game. Use 'new' or 'resume '.") + return true + } + + g, _ := h.svc.GetGame(h.gameID) + if g.NextPlayer().Type != core.PlayerHuman { + h.view.ShowMessage("It's not a human player's turn. Press ENTER to execute computer move.") + return true + } + + if err := h.svc.MakeHumanMove(h.gameID, cmd.Args[0]); err != nil { + h.view.ShowError(fmt.Errorf("invalid move: %v", err)) + return true + } + + // Get result and display human move + g, _ = h.svc.GetGame(h.gameID) + result := g.LastResult() + if result != nil { + h.view.ShowHumanMove(result.Move) + } + + board, _ := h.svc.GetCurrentBoard(h.gameID) + h.view.DisplayBoard(board) + + if result != nil && result.GameState != core.StateOngoing { + h.view.ShowGameOver(result.GameState) + h.gameID = "" + } + + case cli.CmdUndo: + if h.gameID == "" { + h.view.ShowMessage("No active game.") + return true + } + + // Parse undo count + count := 1 + if len(cmd.Args) > 0 { + if n, err := strconv.Atoi(cmd.Args[0]); err == nil && n > 0 { + count = n + } else { + h.view.ShowMessage("Invalid undo count. Usage: undo [count]") + return true + } + } + + if err := h.svc.Undo(h.gameID, count); err != nil { + h.view.ShowError(err) + } else { + if count == 1 { + h.view.ShowMessage("Move undone") + } else { + h.view.ShowMessage(fmt.Sprintf("%d moves undone", count)) + } + + board, _ := h.svc.GetCurrentBoard(h.gameID) + h.view.DisplayBoard(board) + } + + case cli.CmdColor: + if len(cmd.Args) < 1 { + h.view.ShowMessage("Usage: color ") + return true + } + + theme := cli.ColorTheme(cmd.Args[0]) + if err := h.view.SetTheme(theme); err != nil { + h.view.ShowError(err) + } else { + h.view.ShowMessage(fmt.Sprintf("Color theme set to: %s", theme)) + if h.gameID != "" { + board, _ := h.svc.GetCurrentBoard(h.gameID) + h.view.DisplayBoard(board) + } + } + + case cli.CmdVerbose: + verbose := h.view.ToggleVerbose() + h.view.ShowMessage(fmt.Sprintf("Verbose mode: %t", verbose)) + + case cli.CmdHistory: + if h.gameID == "" { + h.view.ShowMessage("No active game.") + return true + } + g, _ := h.svc.GetGame(h.gameID) + h.view.ShowGameHistory(g) + + case cli.CmdHelp: + h.view.ShowHelp() + } + + return true +} + +func (h *CLIHandler) executeComputerMove() { + result, err := h.svc.MakeComputerMove(h.gameID) + if err != nil { + h.view.ShowError(fmt.Errorf("engine error: %v", err)) + h.gameID = "" + return + } + + h.view.ShowComputerMove(result) + board, _ := h.svc.GetCurrentBoard(h.gameID) + h.view.DisplayBoard(board) + + if result.GameState != core.StateOngoing { + h.view.ShowGameOver(result.GameState) + h.gameID = "" + } +} + +// Starts a new game with player type selection +func (h *CLIHandler) handleNewGame(fen string) bool { + // Get player types + h.view.ShowPrompt("Select White player (h/c): ") + whiteInput := h.view.ReadLine() + var whiteType core.PlayerType + if whiteInput == "c" || whiteInput == "computer" { + whiteType = core.PlayerComputer + } else { + whiteType = core.PlayerHuman + } + + h.view.ShowPrompt("Select Black player (h/c): ") + blackInput := h.view.ReadLine() + var blackType core.PlayerType + if blackInput == "c" || blackInput == "computer" { + blackType = core.PlayerComputer + } else { + blackType = core.PlayerHuman + } + + // Create new game + h.gameID = uuid.New().String() + var fenArray []string + if fen != "" { + fenArray = []string{fen} + } + + if err := h.svc.NewGame(h.gameID, whiteType, blackType, fenArray...); err != nil { + h.view.ShowError(fmt.Errorf("could not start the game: %v", err)) + h.gameID = "" + return true + } + + h.view.ShowMessage("Game started.") + board, _ := h.svc.GetCurrentBoard(h.gameID) + h.view.DisplayBoard(board) + + return true +} \ No newline at end of file diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..9f0d55f --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,29 @@ +// FILE: internal/transport/transport.go +package transport + +import ( + "chess/internal/board" + "chess/internal/core" + "chess/internal/game" +) + +// Handler processes user commands independent of transport medium +type Handler interface { + HandleNewGame(id string, fen string, whiteType, blackType core.PlayerType) error + HandleMove(gameID, move string) error + HandleUndo(gameID string) error + HandleGetBoard(gameID string) (*board.Board, error) + HandleGetGame(gameID string) (*game.Game, error) +} + +// View abstracts display/output operations +type View interface { + DisplayBoard(b *board.Board) + ShowMessage(msg string) + ShowError(err error) + ShowGameHistory(g *game.Game) + ShowComputerMove(player core.Color, move string, depth, score int) + ShowHumanMove(move string) + ShowGameOver(state core.State) + ShowPrompt(prompt string) +}