v0.1.0 chess game in go, using external stockfish engine
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.idea
|
||||||
|
dev
|
||||||
|
log
|
||||||
|
bin
|
||||||
|
build.sh
|
||||||
28
LICENSE
Normal file
28
LICENSE
Normal 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
48
README.md
Normal 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
26
cmd/chess/main.go
Normal 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
24
cmd/chessd/main.go
Normal 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
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module chess
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/google/uuid v1.6.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal 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
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