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

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