v0.1.0 chess game in go, using external stockfish engine
This commit is contained in:
88
internal/board/board.go
Normal file
88
internal/board/board.go
Normal 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
293
internal/cli/cli.go
Normal 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
53
internal/core/core.go
Normal 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
231
internal/engine/engine.go
Normal 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
121
internal/game/game.go
Normal 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
232
internal/service/service.go
Normal 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
|
||||
}
|
||||
248
internal/transport/cli/handler.go
Normal file
248
internal/transport/cli/handler.go
Normal 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
|
||||
}
|
||||
29
internal/transport/transport.go
Normal file
29
internal/transport/transport.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user