v0.1.0 chess game in go, using external stockfish engine

This commit is contained in:
2025-10-26 17:34:50 -04:00
commit 8ba4357920
15 changed files with 1433 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea
dev
log
bin
build.sh

28
LICENSE Normal file
View File

@ -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.

48
README.md Normal file
View File

@ -0,0 +1,48 @@
<table>
<tr>
<td>
<h1>Go Chess</h1>
<p>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License"></a>
</p>
</td>
</tr>
</table>
# 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

26
cmd/chess/main.go Normal file
View File

@ -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
}

24
cmd/chessd/main.go Normal file
View File

@ -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)
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module chess
go 1.24
require github.com/google/uuid v1.6.0

2
go.sum Normal file
View File

@ -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=

88
internal/board/board.go Normal file
View File

@ -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]
}

293
internal/cli/cli.go Normal file
View File

@ -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 <FEN> - Resume from a specific board position
<move> - Make a move (e.g., e2e4, g1f3)
undo [count] - Undo last move(s), default 1
color <theme> - 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 <FEN>, <move>, 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'.")
}

53
internal/core/core.go Normal file
View File

@ -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
}

231
internal/engine/engine.go Normal file
View File

@ -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()
}
}

121
internal/game/game.go Normal file
View File

@ -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
}

232
internal/service/service.go Normal file
View File

@ -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
}

View File

@ -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 <FEN string>")
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 <FEN>'.")
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 <off|brown|green|gray>")
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
}

View File

@ -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)
}